编写高效松耦合的模块,体现的是功力,而完善的测试用例则是习惯。包括了一些异常场景的积累,代码重构时的验证等等,编写有效的测试用例就尤为重要。
而 C 语言,由于其偏向于底层,导致不能像 Java、Python、GO 那样提供了成熟的测试框架。
这里简单介绍一下基于 cmocka 修改的测试框架,会通过一些宏定义处理部分问题,当然,真正使用时还需要一些其它的技巧。
Mock
所谓的 mock 测试,简单来说就是仿造某个被调用的函数,一般来说这个函数可能是系统调用、某个库的函数,有可能需要建立连接等等。
对于一个函数来说,如果不考虑其本身带来的副作用 (实际上可以在测试时通过宏进行屏蔽),一般只需要关注其输入和输出即可,那么对于 mock 而言,也就是如何校验入参,并构造输出内容。
所谓函数副作用是指当调用函数时,除了返回函数值之外,还对主调用函数产生附加的影响,例如修改全局变量,这会带来一些不必要的麻烦,导致难以查找的错误、降低代码可读性等。
这里实现了一个简单的 mock 库,很多实现参考了上述的 cmocka 实现。
使用方法
在使用时基本上可以分为两步:A) 根据测试场景,明确期望的输入以及输出参数;B) 在 mock 后的函数中检查相关的参数,并构造输出。
测试用例
每个测试用例可以作为一个单独的调用过程,可以通过 expect_XXX()
确定某个函数对应的入参值;并使用 will_return()
确定 mock 后函数的返回值信息。
然后在 mock 后的函数中进行模拟,例如通过 check_expected()
检查在测试用例中通过 expect_XXX()
函数设置的入参检查值;通过 mock()
返回之前通过 will_return()
设置的返回值。
在使用 mock()
函数时,因为该函数可以保存指针、数值、浮点数等信息,可以按照参数的返回信息进行强制转换,例如 return (int) mock();
。
实现细节
expect_XXX()
类型的函数会向 global_function_parameter_map_head
链表中添加节点,一般是 funcation
+ parameter
两个参数,其中保存的对象为 struct check_para_event
。
will_return()
函数会向 global_function_result_map_head
链表中添加节点,包含的只是 funcation
一个参数,其中保存的对象为 struct symbol_value
。
在上述两种方式在添加到列表时,会新建一个 struct symbol_map_value
对象来保存值,主要是为了实现层级嵌套关系。
API
返回值检查。
will_return(func, value) # 只返回一次mock值,等价于will_return_count(func, value, 1)
will_return_count(func, value, count) # 可以返回count次的mock值,大于count次将会报错,如果有剩余没有返回同样报错
will_return_always(func, value) # 总是返回数据,等价于will_return_count(func, value, -1)
参数检查。
expect_check() # 是其它expect_XXX类型函数的最终调用接口,区别在与检查是否相等的函数不同
expect_string(func, para, str) # 设置某个函数字符串参数的预期值
expect_value(func, para, value) # 数值类型检查
expect_in_set(func, para, array) # 检查值是否在数组中,注意数组应该是maxint_t []
expect_in_range(func, para, min, max) # 检查值是否在 [min, max] 范围内
在 mock 测试时,隐含了一个引用次数的计数,可以通过 expect_XXX_count()
函数设置引用的次数,表示被几次函数调用。每次,如果有些参数没有被检查到,那么实际上也意味着错误。
模拟接口
对于一些函数的单元测试很简单,实际上就是构建测试用例,然后判断函数返回结果即可。但是有的时候 A 函数调用 B ,然后调用 C … …,我们希望,在完成 C 的单元测试之后,mock 函数 C 的功能,然后在针对 B 编写单元测试。
这也就意味着需要针对函数提供一些相关的 mock 调用,主要包括了:A) 系统函数调用,模拟系统调用失败;B) 已单元测试过的函数,用于解耦测试用例。
系统调用
通常的系统调用包括了 open()
write()
read()
close()
等等,在模拟时可以检查这些参数,然后返回成功或失败。当 mock 系统调用的时候,实际上有几种方式可供参考。
GCC Linker
也就是使用的 GCC 的连接器选项,不过一般会直接使用 gcc
命令而非 ld
,此时可以通过 -Wl,--wrap=...
将参数通过 gcc
传递给 ld
。
例如,使用 -Wl,--wrap=open
参数进行编译,此时,GCC 实际上会将 open()
替换为 __wrap_open()
函数,也就是说连接的是该函数。如果用户希望调用真正的函数,那么就可以使用 __real_open()
函数。
使用示例如下,如果要调用真正的 open()
函数,可以直接使用 __real_open()
函数。
#include <stdio.h>
#include <fcntl.h>
int __real_open(const char *path, int flags);
int __wrap_open(const char *path, int flags)
{
fprintf(stdout, "===> Open path '%s' flags '0x%x'\n", path, flags);
return -15;
}
int main(void)
{
int rc;
rc = open("/tmp/1", O_CLOEXEC);
if (rc < 0) {
fprintf(stderr, "Open file failed, rc %d.\n", rc);
return -1;
}
return 0;
}
函数替换
对于在同一个文件中的函数,GCC 在编译时不会将其标记为 unresolved
,那么上述的 wrap 机制也就无效。
这一问题,GCC 提供了一个简单的 weak symbols
机制,默认是 strong symbols
,对于后者如果再定义一个相同名称的函数,那么就会报 multiple defination
的错误。
设置 weak symbols
有两种方式:A) 使用 GCC 编译时传递相关的参数;B) 在函数实现时增加一个 __attribute__((weak))
的注释。前者适用于不方便修改代码的场景,例如在使用一些三方的库。
注意,如果在使用 B 方式的时候,其他用户实际上可以直接替换掉我们实现的函数,实际上这会引入一些问题,所以建议使用宏进行定义。
示例
glibc 中大部分的接口都是使用的 weak symbols
,仍然以 open()
函数为例。
在 glibc 中的实现在 sysdeps/unix/sysv/linux/open.c
文件中,代码如下:
int
__libc_open (const char *file, int oflag, ...)
{
int mode = 0;
if (__OPEN_NEEDS_MODE (oflag)) {
va_list arg;
va_start (arg, oflag);
mode = va_arg (arg, int);
va_end (arg);
}
return SYSCALL_CANCEL (openat, AT_FDCWD, file, oflag, mode);
}
libc_hidden_def (__libc_open)
weak_alias (__libc_open, __open)
libc_hidden_weak (__open)
weak_alias (__libc_open, open)
也就是说,可以在用户的代码中直接定义一个相同的函数 (函数名+参数相同) ,编译器在连接时会自动将弱引用覆盖。
#include <stdio.h>
#include <fcntl.h>
int open(const char *path, int flags, ...)
{
fprintf(stdout, "===> Open path '%s' flags '0x%x'\n", path, flags);
return -15;
}
int main(void)
{
int rc;
rc = open("/tmp/1", O_CLOEXEC);
if (rc < 0) {
fprintf(stderr, "Open file failed, rc %d.\n", rc);
return -1;
}
return 0;
}
也就是说,glibc 中实现的 open()
函数实际上是支持多个参数的。
注意,如果这里需要使用 glibc 的动态库中的函数,那么就需要通过 dlsym(RTLD_NEXT, "open")
动态查找到相关的实现,然后再调用。
最佳实践
在测试时添加一个 __TEST__
宏,同时对于一些静态函数为了方便测试,可以增加如下的宏定义,这样可以在测试阶段对一些内置函数进行测试。
#ifdef __TEST__
/* Make some internal functions visible for testing */
#define STATIC_TESTABLE
#else
#defein STATIC_TESTABLE static
#endif
对于内存分配函数来说,例如 malloc()
realloc()
strdup()
,可以通过自定义的函数直接覆盖,也可以通过连接器的 wrap 功能实现,但是这有可能会影响到其它的一些工具,例如代码覆盖率等。
所以,这里建议的方式是采用宏或者 inline
定义一些替换函数,例如 xxx_malloc()
等。
#ifdef __TEST__
#define xmalloc mock_malloc
#else
#define xmalloc malloc
#endif
这样,可以将 mock_malloc()
作为一个通用的实现。
参考
- 介绍如何写 C 的单元测试 Unit testing C code with CMocka 。