7、测试夹具(Test Fixture):setUp与tearDown函数的使用、共享资源的初始化与释放
好,咱们今天聊一个非常实在的话题——测试夹具。说白了,就是测试前的准备工作,和测试后的清理工作。
我记得刚入行那会儿,写单元测试就是一股脑地写测试用例。测试一个函数,前面先malloc一块内存,再初始化几个全局变量,然后调用被测函数,最后还得记得free掉。每个测试用例都这么来一遍,代码重复得让人崩溃。后来我才知道,原来有专门的机制来处理这事儿——就是setUp和tearDown。
什么是测试夹具?
测试夹具,英文叫Test Fixture。你可以把它理解成「测试的舞台布景」。每次测试前,你得把舞台搭好——初始化资源、设置状态、准备数据。测试完了,再把舞台拆掉——释放内存、恢复全局状态、关闭文件句柄。
为什么要这么做?因为每个测试用例都需要一个「干净」的起点。你想想看,如果测试A改了某个全局变量,测试B跑的时候直接用了这个被改过的值,那测试B的结果还靠谱吗?肯定不靠谱。这就是测试之间的「耦合」,是单元测试的大忌。
setUp和tearDown的基本用法
在C语言的单元测试框架中,比如CUnit、Unity、CMocka,都有setUp和tearDown的机制。咱们以Unity为例,看看怎么用。
#include "unity.h"
// 被测模块需要的全局资源
static int* buffer = NULL;
static int buffer_size = 1024;
// 每个测试用例运行前都会调用
void setUp(void) {
buffer = (int*)malloc(buffer_size * sizeof(int));
TEST_ASSERT_NOT_NULL(buffer);
memset(buffer, 0, buffer_size * sizeof(int));
// 我习惯在这里也打印一下日志,方便调试
// printf("setUp: buffer allocated at %p\n", buffer);
}
// 每个测试用例运行后都会调用
void tearDown(void) {
free(buffer);
buffer = NULL;
// 释放后置空,防止野指针
}
// 测试用例1:写入数据
void test_write_data(void) {
buffer[0] = 42;
TEST_ASSERT_EQUAL_INT(42, buffer[0]);
}
// 测试用例2:读取默认值
void test_read_default(void) {
// 这里buffer已经被setUp重新初始化为0了
TEST_ASSERT_EQUAL_INT(0, buffer[0]);
}
int main(void) {
UNITY_BEGIN();
RUN_TEST(test_write_data);
RUN_TEST(test_read_default);
return UNITY_END();
}
你看,test_write_data往buffer[0]写了42,但test_read_default跑的时候,buffer已经被setUp重新分配并清零了。所以test_read_default拿到的buffer[0]是0,而不是42。这就是测试夹具的价值——每个用例都是「干净」的。
共享资源的初始化与释放
实际项目中,共享资源可不止是内存。我遇到过的情况包括:
- 文件句柄:测试日志模块,每次测试前要打开日志文件,测试完要关闭
- 网络连接:测试通信协议,需要建立socket连接,测试完断开
- 硬件外设:嵌入式开发中,测试I2C、SPI驱动,需要初始化寄存器,测试完恢复默认值
- 全局状态机:测试状态切换,每次测试前要把状态机复位到初始状态
咱们来看一个更贴近嵌入式场景的例子——测试一个环形缓冲区模块:
#include "unity.h"
#include "ring_buffer.h"
static ring_buffer_t rb;
static uint8_t storage[256];
void setUp(void) {
ring_buffer_init(&rb, storage, sizeof(storage));
// 确保初始状态是空的
TEST_ASSERT_TRUE(ring_buffer_is_empty(&rb));
}
void tearDown(void) {
// 清空缓冲区,释放内部资源(如果有的话)
ring_buffer_deinit(&rb);
// 我习惯把存储区也清零,防止残留数据影响下一个用例
memset(storage, 0, sizeof(storage));
}
void test_push_one_element(void) {
ring_buffer_push(&rb, 0xAB);
TEST_ASSERT_EQUAL_UINT8(0xAB, ring_buffer_peek(&rb));
TEST_ASSERT_FALSE(ring_buffer_is_empty(&rb));
}
void test_push_overflow(void) {
// 填满缓冲区
for (int i = 0; i < 256; i++) {
ring_buffer_push(&rb, (uint8_t)i);
}
// 再push一个应该返回错误
TEST_ASSERT_EQUAL_INT(RB_ERR_FULL, ring_buffer_push(&rb, 0xFF));
}
setUp和tearDown的执行顺序
搞清楚执行顺序很重要。我画了一张图,帮你理解:
执行顺序很清晰:
- 运行测试用例1:先调setUp(),再调test_case_1(),最后调tearDown()
- 运行测试用例2:先调setUp(),再调test_case_2(),最后调tearDown()
注意,即使测试用例中断言失败,tearDown依然会被调用。这是框架保证的,目的就是防止资源泄漏。
避坑指南:我踩过的几个坑
这些年写测试夹具,我踩过不少坑。分享几个典型的:
我曾经在setUp里直接malloc,没检查NULL。结果某个测试环境内存紧张,malloc返回NULL,后续测试直接段错误。排查了半天才发现是setUp的问题。所以我现在setUp里一定会加断言检查。
这个最要命。测试跑一次没事,跑一百次内存就爆了。尤其是嵌入式环境,内存本来就紧张。我后来养成了习惯:写完tearDown后,用valgrind跑一遍,确认没有内存泄漏。
有一次我在tearDown里调了一个函数,这个函数内部改了某个全局标志位。结果下一个测试用例的setUp里依赖这个标志位,导致行为异常。教训是:tearDown只做「清理」,不要做「设置」。
什么时候用setUp/tearDown,什么时候不用?
不是所有测试都需要夹具。我个人的判断标准是这样的:
| 场景 | 是否使用夹具 | 原因 |
|---|---|---|
| 测试纯函数(无副作用) | 不需要 | 输入输出确定,不需要外部状态 |
| 测试需要初始化全局变量 | 建议使用 | 保证每个用例的全局状态一致 |
| 测试需要分配动态内存 | 必须使用 | setUp分配,tearDown释放,防止泄漏 |
| 测试涉及硬件外设 | 必须使用 | 初始化硬件状态,测试完恢复 |
| 测试需要打开文件/网络连接 | 必须使用 | 资源有限,用完必须释放 |
进阶技巧:分组夹具
有些测试框架支持分组夹具。什么意思呢?就是一组测试用例共享同一个setUp/tearDown,但不同组可以有不同的夹具。这在大型项目中特别有用。
比如在CMocka中,你可以这样写:
// 组1:测试正常情况
static int setup_normal(void **state) {
*state = malloc(sizeof(my_struct_t));
// 初始化正常状态
return 0;
}
static int teardown_normal(void **state) {
free(*state);
return 0;
}
// 组2:测试异常情况
static int setup_error(void **state) {
*state = NULL; // 模拟错误状态
return 0;
}
static int teardown_error(void **state) {
// 不需要释放,因为state是NULL
return 0;
}
int main(void) {
const struct CMUnitTest tests_normal[] = {
cmocka_unit_test_setup_teardown(test_func1, setup_normal, teardown_normal),
cmocka_unit_test_setup_teardown(test_func2, setup_normal, teardown_normal),
};
const struct CMUnitTest tests_error[] = {
cmocka_unit_test_setup_teardown(test_func3, setup_error, teardown_error),
};
cmocka_run_group_tests(tests_normal, NULL, NULL);
cmocka_run_group_tests(tests_error, NULL, NULL);
return 0;
}
这样,正常情况的测试用一套夹具,异常情况的测试用另一套。代码更清晰,也更容易维护。
总结一下
测试夹具不是什么高深的技术,但它能帮你写出更干净、更可靠的单元测试。记住三个要点:
- setUp:为每个测试用例准备「干净」的起点
- tearDown:测试结束后清理现场,不留痕迹
- 独立:每个测试用例互不干扰,这是单元测试的基石
嗯,关于测试夹具就聊到这儿。你写测试的时候,不妨也试试这个思路,相信你会爱上它的。
公众号:蓝海资料掘金营,微信deep3321