C Mock 使用以及机制介绍

2016-10-16 language c/cpp

编写高效松耦合的模块,体现的是功力,而完善的测试用例则是习惯。包括了一些异常场景的积累,代码重构时的验证等等,编写有效的测试用例就尤为重要。

而 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() 作为一个通用的实现。

参考