C单元测试框架——cmocka

在自动化验证技术成熟之前,我们依旧需要测试,能否编写优秀的模块,体现的是能力,而为代码编写完善的测试用例,体现的则是习惯。

虽然测试并不能说明什么问题,但目前我们并无任何备选方案,相信在很长一段时间内,完善的测试用例对于项目而言都是弥足珍贵的。

有些时候会觉得编写测试用例太烦,但是要知道,至少我们还能通过测试用例来进行测试,有很多领域是无法通过如此“傻瓜式”的测试来达成目的的。

当然要是代码简单到用编译检测就能够排除bug,那测试就没什么用处了。但问题是哪个稍微复杂一点的程序能够做到?

为什么要写本文呢?不得不提到我目前对程序开发的理念:

写烂代码->维护代码->推翻重写

在日常开发过程中,我经常会因为各种原因重写一些模块或是进行一些重构,而我很难长时间集中注意力,且短时间内code review可能无法快读发现问题,这个时候,遗留的测试用例就能够在一定程度上帮助我。所以,完善的测试用例是一笔宝贵的财富。

本文主要介绍C语言的一款单元测试框架——cmocka,相比于junit之类的测试框架而言,会显得稍微繁琐和奇特,这主要是因为C的抽象层次不够高造成的,框架只能依托大量宏来解决问题,因此不可避免的需要用户处理一些额外的步骤。

0x00 cmocka的特性

请输入图片描述

如上是一张来自cmocka官网的图,使用cmocka编写的代码大体就是这样,其实还是能够接受的。

cmocka支持如下特性:

  1. 提供跨平台支持
  2. 文档完整详细
  3. 无第三方库依赖
  4. 非fork()执行
  5. 提供对于信号的处理
  6. 提供基本的抽象,如Test Fixture等,支持setup和teardown函数,集成mock
  7. 支持多种格式输出(编译时指定)
  8. 提供基本的内存检测,如内存泄露,buffer的上下溢检测

对于C程序而言,很多地方涉及到信号和系统调用,这个时候想要进行充分的测试,就需要强大的mock和信号处理的支持了,就上述问题而言,cmocka还是能够满足要求的,当然也有些许遗憾,多格式输出需要在编译cmocka时通过参数的方式指定。

0x01 cmocka的组成

请输入图片描述

上图是官方文档中提供的模块图,我们可以将其与cmocka提供的功能进行一些简单的映射。

  • Running Tests 是整个测试的运行函数
  • Standard Assertions 对标准assert的一种替换方案,防止测试被assert打断
  • Checking Parameters 用于对函数参数的检测
  • Mock Objects 用于mock对象,常用作mock函数的返回,实现方式与Checking Parameter类似
  • Dynamic Memory Allocation 用于对内存的检测
  • Assert Macros 包含了常用的断言

下文大致会覆盖上述内容,其中Standard Assertions和Dynamic Memory Allocation将不做阐述,对于后者,个人建议使用valgrind。

0x02 执行框架

本节我们通过官方的例子,来介绍测试代码的骨架,细节部分暂不提及,上代码:

#include <stdarg.h>
#include <stddef.h>
#include <setjmp.h>
#include <cmocka.h>
/* A test case that does nothing and succeeds. */
static void null_test_success(void **state) {
    (void) state; /* unused */
}
int main(void) {
    const struct CMUnitTest tests[] = {
        cmocka_unit_test(null_test_success),
    };
    return cmocka_run_group_tests(tests, NULL, NULL);
}

如果各位在之前接触过unittest,应该不难猜到这里的null_test_success函数就是具体的test case了,当然这里的test case内部并没有什么内容。

每个测试代码的开头都需要include 3个头文件,这是官方文档中明确要求的,它们分别是:

  • stdarg.h
  • stddef.h
  • setjmp.h

在main函数中,可以看到一个类型为CMUnitTest的结构数组,每个CMUnitTest结构体都可以包含若干test case,以及可选的startup和teardown,startup和teardown就是test fixure,它们可以被若干test case共享,负责初始化或者销毁资源的操作。

cmocka_unit_test是一个宏,如果不深究的话,完全可以把它当做函数。除此之外,还有另外3个宏:

  1. cmocka_unit_test_startup(case, startup)
  2. cmocka_unit_test_teardown(case, teardown)
  3. cmocka_unit_test_startup_teardown(case, startup, teardown)

这个应该就不需要我解释了,名字已经表示得很清楚了。

cmocka_run_group_tests是一个函数,用来指示框架运行测试,可以为测试集指定全局的startup(参数2)和teardown(参数3)。此外,还有一个类似的函数,cmocka_run_group_tests_name它和前者基本相同,但是它可以通过第一个参数指定测试集的名字,其余3个参数和前者一致。

上述大致就是Running Tests模块的全部内容了,这里并未给出已经废弃的函数/宏,另外,还有3个比较使用的函数:

  1. void fail(void) 立即结束当前test case,报错
  2. void fail(const char* msg) 立即结束当前test case,报错并打印错误信息
  3. void skip(void) 跳过当前test case

请勿在test fixure中使用上述3个函数。

0x03 常用断言

我们没法在cmocka测试框架中使用assert.h中的断言,因为这会导致框架无法捕获错误,直接段错误。

为此cmocka框架为C语言中的常用类型提供了相对完善的断言函数,如下对常用的断言做简要阐述:

1. 表达式

  • assert_true(expr)
  • assert_false(expr)

其中expr表示C语言的表达式,相对简单,assert_true()和assert()的用法差不多,而assert_false()则是正好相反。

2. 整型

  • assert_int_equal(int a, int b)
  • assert_int_not_equal(int a, int b)

断言int型变量a与b是否相等。

  • assert_in_range(LargestIntegralType v, LargestIntegralType min, LargestIntegralType max)
  • assert_not_in_range(LargestIntegralType v, LargestIntegralType min, LargestIntegralType max)

断言v是否在[min, max]的范围之内。

  • assert_in_set(LargestIntegralType v, LargestIntegralType vals[], size_t count)
  • assert_not_in_set(LargestIntegralType v, LargestIntegralType vals[], size_t count)

断言v是否属于vals集合。

3. 指针

  • assert_ptr_equal(void a, void b)
  • assert_ptr_not_equal(void a, void b)
  • assert_null(void* p)
  • assert_non_null(void* p)

判断两个指针变量a与b是否相等,或者p指针是否为NULL。

4. 内存块

  • assert_memory_equal(const void a, const void b, size_t size)
  • assert_memory_not_equal(const void a, const void b, size_t size)

断言长度为size的a与b指向的内存内容是否相等。

5. string类型

C语言并没有字符串,只有char*,虽然我们也能使用内存块断言,但cmocka还是提供了字符串的断言。

  • assert_string_equal(const char a, const char b)
  • assert_string_not_equal(const char a, const char b)

断言a与b指向的字符串是否相等,而此处的size相当于strlen(a)或strlen(b)。

6. 函数返回值

  • assert_return_code(int rc, int error)

断言函数的返回值rc是否小于0,并将errno传入,如果出错则将错误信息加入到输出中。感觉这个断言比较鸡肋。

0x04 mock对象

Java程序员应该或多或少都用过mockit,它和junit一起用相当方便,cmocka的mock object也提供类似功能,只不过实现的方式稍有不同罢了。

所谓mock,在C里就是仿造一个函数,可能有人会问,为何要mock,而不是使用真是的函数?原因在于,在很多情况下,我们无法或者是不需要对目标函数进行测试,这个函数可能是系统调用,也可能属于某个函数库,那么这个是否,无法也不应该对其进行测试。我们要做的只是伪造一个函数,即可完成我们需要的效果。

关于一个函数,在不考虑副作用的前提下,我们只需要关注它的输入(参数)和输出(返回值)即可。至于副作用,暂时没有比较好的解决办法。类似的,在程序验证中,也会对副作用进行一定的假设,或者说是一定的约束,但又无法杜绝,毕竟C语言不是函数式语言。

如下,我们按照对参数和对返回值的处理事件进行阐述,这里的事件和前文的断言使用方式有点不同,事件是先指定未来会出现什么事件,然后在未来某个时间点触发事件。

可以看做是如下的方式:

指定参数事件
指定返回值事件
rc func(para) {
    触发参数事件
    return 触发返回值事件
}

1. 参数断言

  • check_expected(#para)
  • check_expected_ptr(#para)

检查函数,但并不指出应该检查什么,这些都已经在函数调用之前通过如下的expect_*函数指定了。其中后者用于对指针类型的检查事件。

  • expect_any(#func, #para)
  • expect_any_count(#func, #para, size_t count)

指定func的para将会被传递的事件,如果有count,就说该事件会被重复count次。

  • expect_check(#func, #para, #check_func, const void* check_data)

指定某个para将会触发调用check_func函数的事件,该函数的两个参数分别为para和check_data,若检查通过,则返回0。这个在文档中写的不是很明确,我通过测试得出的上述结论。

  • expect_in_range(#func, #para, LargestIntegralType min, LargestIntegralType max)
  • expect_not_in_range(#func, #para, LargestIntegralType min, LargestIntegralType max)
  • expect_in_range_count(#func, #para, LargestIntegralType min, LargestIntegralType max, size_t count)
  • expect_not_in_range_count(#func, #para, LargestIntegralType min, LargestIntegralType max, size_t count)
  • expect_in_set(#func, #para, LargestIntegralType vals[])
  • expect_not_in_set(#func, #para, LargestIntegralType vals[])
  • expect_in_set_count(#func, #para, LargestIntegralType vals[], size_t count)
  • expect_not_in_set_count(#func, #para, LargestIntegralType vals[], size_t count)
  • expect_memory(#func, #para, void* mem, size_t size)
  • expect_not_memory(#func, #para, void* mem, size_t size)
  • expect_memory_count(#func, #para, void* mem, size_t size, size_t count)
  • expect_not_memory_count(#func, #para, void* mem, size_t size, size_t count)
  • expect_string(#func, #para, const char* str)
  • expect_not_string(#func, #para, const char* str)
  • expect_string_count(#func, #para, const char* str, size_t count)
  • expect_not_string_count(#func, #para, const char* str, size_t count)
  • expect_value(#func, #para, LargestIntegralType val)
  • expect_not_value(#func, #para, LargestIntegralType val)
  • expect_value_count(#func, #para, LargestIntegralType val, size_t count)
  • expect_not_value_count(#func, #para, LargestIntegralType val, size_t count)

上述的这些expect函数基本和assert中的一致,其中expect_value和assert_int_equal类似,这里不再赘述。

2. 返回值断言

  • mock()
  • mock_ptr_type()

出发返回值事件,其中后者返回指针。

  • will_return(#func, LargestIntegralType val)
  • will_return_always(#func, LargestIntegralType val)
  • will_return_count(#func, LargestIntegralType val, int count)

指定函数的返回事件,可以选择重复count次或者一直返回。

0x05 总结

如上基本就是cmocka测试框架的全部内容了,函数虽多,相信用过几次就会习惯了。

对于一般的程序测试,cmocka框架基本够用了,但是它还无法完全应对C语言的灵活性带来的复杂度,因此还是存在些许缺点的。

比如可能会被signal中断的函数就无法使用cmocka来测,输出文件类型需要在编译时指定等。

如果有需要,未来我可能会修改该测试框架,届时再做总结。

说两句: