文章目录
1. 前言
在学习单元测试的过程中,使用「模拟框架」隔离依赖是一项必须要掌握的技术。目前模拟框架有很多,琳琅满目,参差不齐。针对C语言模拟框架的初学者,我推荐FFF框架,因为该框架简单,易上手,而且有助于初学者掌握模拟框架的幕后原理。有了基础,再去学习更复杂、更高端的框架,就游刃有余了。
注:在众多的C语言模拟框架中,FFF框架算是一个比较冷门的框架,至少截止我书稿本文时是如此,在网上鲜有相关资料,可见算不上主流,但FFF框架很适合模拟框架的初学者,这也正是我撰写本文的目的。
2. FFF框架简介
官网:https://github.com/meekrosoft/fff
FFF全称Fake Function Framework,是一个用于单元测试的C语言轻量型模拟框架。整个框架就一个头文件fff.h,全部用宏定义实现的框架,非常简洁,你只要include该头文件,就能使用该框架了。
FFF框架的优点:
- 很容易创建C语言的存根和模拟对象。
- 它很简单,只需包含一个头文件,就可以开始了。
3. 入门体验
3.1 下载fff.h头文件
git clone https://github.com/meekrosoft/fff.git
我们只需要其中的fff.h头文件即可,其他不需要。
说明:克隆下来的代码中有个gtest文件夹,这是谷歌的Google Test单元测试框架,FFF框架是模拟框架,需要区分两者,本文重点讲解的是FFF模拟框架,不会涉及单元测试框架。
3.2 初次体验
直接上例子:
void UI_init(void)
{
DISPLAY_init();
}
UI_init函数调用了DISPLAY_init接口。在没有DISPLAY_init接口实现代码的情况下(只知道接口的声明,如下所示),如何对UI_init函数进行单元测试?
void DISPLAY_init();
使用FFF框架很容易创建DISPLAY_init模拟函数,只要三行代码:
#include "fff.h"
DEFINE_FFF_GLOBALS;
FAKE_VOID_FUNC(DISPLAY_init);
完整测试代码下如下:
// test.c
#include <stdio.h>
#include <stdlib.h>
#include <assert.h>
#include "fff.h"
DEFINE_FFF_GLOBALS;
FAKE_VOID_FUNC(DISPLAY_init);
void UI_init(void)
{
DISPLAY_init();
}
#define ASSERT_EQ(A, B) assert((A) == (B))
int main()
{
UI_init();
ASSERT_EQ(DISPLAY_init_fake.call_count, 1);
return 0;
}
可以编译成功:
gcc -o test test.c
为何如此神奇,只要两个宏(DEFINE_FFF_GLOBALS 和 FAKE_VOID_FUNC)就能模拟出接口,FFF框架幕后到底干了什么?要解开谜底,只要进行一次宏展开就能知晓。对test.c进行宏展开:
gcc -E -P test.c >> test.prescan
查看test.prescan展开后的代码(只列出两个宏对应的关键代码,略有删减):
/* 以下是 DEFINE_FFF_GLOBALS 宏展开后的代码 */
typedef void(*fff_function_t)(void);
typedef struct {
fff_function_t call_history[(50u)];
unsigned int call_history_idx;
} fff_globals_t;
fff_globals_t fff;
/* 以下是 FAKE_VOID_FUNC(DISPLAY_init) 宏展开后的代码 */
typedef struct DISPLAY_init_Fake {
unsigned int call_count;
unsigned int arg_history_len;
unsigned int arg_histories_dropped;
int custom_fake_seq_len;
int custom_fake_seq_idx;
void(*custom_fake)(void);
void(**custom_fake_seq)(void);
} DISPLAY_init_Fake;
DISPLAY_init_Fake DISPLAY_init_fake;
void DISPLAY_init(void)
{
if (DISPLAY_init_fake.call_count < (50u)) {
} else {
DISPLAY_init_fake.arg_histories_dropped++;
}
DISPLAY_init_fake.call_count++;
if (fff.call_history_idx < (50u))
fff.call_history[fff.call_history_idx++] = (fff_function_t)DISPLAY_init;;
if (DISPLAY_init_fake.custom_fake_seq_len) {
if (DISPLAY_init_fake.custom_fake_seq_idx < DISPLAY_init_fake.custom_fake_seq_len) {
DISPLAY_init_fake.custom_fake_seq[DISPLAY_init_fake.custom_fake_seq_idx++]();
} else {
DISPLAY_init_fake.custom_fake_seq[DISPLAY_init_fake.custom_fake_seq_len - 1]();
}
}
if (DISPLAY_init_fake.custom_fake)
DISPLAY_init_fake.custom_fake();
}
void DISPLAY_init_reset(void)
{
memset(&DISPLAY_init_fake, 0, sizeof(DISPLAY_init_fake));
DISPLAY_init_fake.arg_history_len = (50u);
};
耐心看完以上代码,基本上就能知道是咋回事了。
-
DEFINE_FFF_GLOBALS 宏定义了一个结构体全局变量,用于记录、跟踪函数调用的历史记录。
-
FAKE_VOID_FUNC 宏定义是关键所在,其语法为:
FAKE_VOID_FUNC(fn [,arg_types*])
- 该宏定义了一个名为fn的模拟函数,模拟函数返回值类型为void,形参列表为arg_types(可选项,不填就表示形参为void)。
- 该宏还定义了一个名为fn_fake的结构体全局变量,该结构体包含有关模拟函数fn的所有状态信息,例如call_count会在每次调用模拟函数fn时递增。
- 该宏还定义了一个名为fn_reset的函数,用于重置模拟函数fn的状态。fn_reset函数往往是在执行测试用例前(或后)被调用,即setup或teardown中调用,以免影响其他测试用例,或者被其他测试用例影响。
总结起来就是,示例中 FAKE_VOID_FUNC(DISPLAY_init) 宏定义了一个结构体,两个函数:
- DISPLAY_init_Fake 结构体
- void DISPLAY_init(void) 函数
- void DISPLAY_init_reset(void) 函数
至此,基本上就整明白FFF模拟框架的幕后原理了。
4. 深入学习
接下来,更深入的了解下FFF框架。
4.1 模拟函数形参
如果要定义带有形参的模拟函数,比如:
void DISPLAY_output(char * message);
可以这样:
FAKE_VOID_FUNC(DISPLAY_output, char *);
测试用例(UI_write_line函数会调用DISPLAY_output接口):
void test(void)
{
char msg[] = "helloworld";
UI_write_line(msg);
ASSERT_EQ(DISPLAY_output_fake.call_count, 1);
ASSERT_EQ(strcmp(DISPLAY_output_fake.arg0_val, msg), 0);
}
在FAKE_VOID_FUNC宏定义中,函数名之后紧接的是函数的形参列表(示例中是char指针),每个形参在fn_fake结构体中都有argN_val变量与之对应(N从0开始)。欲知代码详情,宏展开。
4.2 模拟函数返回值
如果要定义带有函数返回值的模拟函数,应该使用FAKE_VALUE_FUNC宏,其语法为:
FAKE_VALUE_FUNC(return_type, fn [,arg_types*]);
- return_type是模拟函数fn的返回值类型,为必填项。
- fn是模拟函数名,为必填项。
- arg_types是模拟函数fn的形参列表,为可选项。
例如:
unsigned int DISPLAY_get_line_capacity();
unsigned int DISPLAY_get_line_insert_index();
可以这样:
FAKE_VALUE_FUNC(unsigned int, DISPLAY_get_line_capacity);
FAKE_VALUE_FUNC(unsigned int, DISPLAY_get_line_insert_index);
测试用例:
void test(void)
{
// 设定 DISPLAY_get_line_insert_index 函数预期返回值
DISPLAY_get_line_insert_index_fake.return_val = 1;
ASSERT_EQ(DISPLAY_get_line_insert_index(), 1);
}
欲知代码详情,宏展开。模拟更复杂的函数,例如:
double pow(double base, double exponent);
可以这样:
FAKE_VALUE_FUNC(double, pow, double, double);
4.3 重置模拟函数状态
好的单元测试会隔离每个测试用例,因此重置模拟函数fn的状态对每个单元测试都至关重要。每个模拟函数fn都有对应的fn_reset接口,用于重置fn的状态信息和呼叫计数。最好的做法是在测试用例的setup中调用fn_reset以重置模拟函数fn的状态。例如:
void setup(void)
{
// Register resets
RESET_FAKE(DISPLAY_init);
RESET_FAKE(DISPLAY_clear);
RESET_FAKE(DISPLAY_output_message);
RESET_FAKE(DISPLAY_get_line_capacity);
RESET_FAKE(DISPLAY_get_line_insert_index);
FFF_RESET_HISTORY();
}
RESET_FAKE 宏会调用相应模拟函数的fn_reset接口以重置fn的状态。而 FFF_RESET_HISTORY 宏用于重置函数调用历史记录,后面章节会讲到函数调用历史记录。
4.4 模拟函数调用记录
如果你要测试一个函数,这个函数依次调用了functionA、functionB、functionA接口,想在测试用例中检查接口调用顺序是否符合预期,怎么测?FFF框架内部维护着所有模拟函数的调用历史记录,因此很容易测试。例如:
FAKE_VOID_FUNC(voidfunc2, char, char);
FAKE_VALUE_FUNC(long, longfunc0);
void test(void)
{
longfunc0();
voidfunc2();
longfunc0();
ASSERT_EQ(fff.call_history[0], (void *)longfunc0);
ASSERT_EQ(fff.call_history[1], (void *)voidfunc2);
ASSERT_EQ(fff.call_history[2], (void *)longfunc0);
}
如果要重置函数调用历史记录,可以使用 FFF_RESET_HISTORY() 宏,一般是在setup中调用该宏。
4.5 模拟函数参数记录
默认情况下,框架内部会记录每个模拟函数的最后十次被调用的参数值,每个伪函数的每个参数值都会记录。
void test(void)
{
voidfunc2('g', 'h');
voidfunc2('i', 'j');
ASSERT_EQ('g', voidfunc2_fake.arg0_history[0]);
ASSERT_EQ('h', voidfunc2_fake.arg1_history[0]);
ASSERT_EQ('i', voidfunc2_fake.arg0_history[1]);
ASSERT_EQ('j', voidfunc2_fake.arg1_history[1]);
}
注意,RESET_FAKE 会清除对应伪函数的参数历史记录。
4.6 模拟函数返回值序列
在单元测试中,有时候会多次调用同一个外部依赖函数,并且期望每次调用都返回不同值。FFF框架实现此操作的方法是,为模拟函数指定返回值序列。例如:
// faking "long longfunc();"
FAKE_VALUE_FUNC(long, longfunc0);
void test(void)
{
long myReturnVals[3] = { 3, 7, 9 };
SET_RETURN_SEQ(longfunc0, myReturnVals, 3);
ASSERT_EQ(myReturnVals[0], longfunc0());
ASSERT_EQ(myReturnVals[1], longfunc0());
ASSERT_EQ(myReturnVals[2], longfunc0());
ASSERT_EQ(myReturnVals[2], longfunc0());
ASSERT_EQ(myReturnVals[2], longfunc0());
}
通过使用SET_RETURN_SEQ宏指定返回值序列,模拟函数将按顺序返回数组中给出的值。当到达序列的末尾时,模拟函数将继续无限期地返回序列中的最后一个值。
4.7 宏备忘录
宏 | 描述 | 例子 |
---|---|---|
FAKE_VOID_FUNC(fn [,arg_types*]); | 定义一个模拟函数,函数返回值类型为void,函数名为fn,函数形参为arg_types(可选项)。 | FAKE_VOID_FUNC(DISPLAY_init); FAKE_VOID_FUNC(DISPLAY_output_message, const char*); |
FAKE_VALUE_FUNC(return_type, fn [,arg_types*]); | 定义一个模拟函数,函数返回值类型为return_type,函数名为fn,函数形参为arg_types(可选项)。 | FAKE_VALUE_FUNC(int, DISPLAY_get_line_insert_index); |
FAKE_VOID_FUNC_VARARG(fn [,arg_types*], …); | 定义一个带有可变参数的模拟函数,函数返回值类型为void,函数名为fn,函数形参为arg_types(可选项)。 | FAKE_VOID_FUNC_VARARG(fn, const char*, …) |
FAKE_VALUE_FUNC_VARARG(return_type, fn [,arg_types*], …); | 定义一个带有可变参数的模拟函数,函数返回值类型为return_type,函数名为fn,函数形参为arg_types(可选项)。 | FAKE_VALUE_FUNC_VARARG(int, fprintf, FILE*, const char*, …) |
RESET_FAKE(fn); | 重置模拟函数fn的状态信息。 | RESET_FAKE(DISPLAY_init); |
4.8 更多学习
更多学习,可以参阅FFF框架官网中的使用手册(英文):https://github.com/meekrosoft/fff