第五章:测试夹具——测试用例的“舞台”与“道具”
各位同学,咱们今天聊一个特别实在的话题——测试夹具。
说实话,我刚入行那会儿,根本不知道什么叫“夹具”。那时候写单元测试,就是傻乎乎地在每个测试函数里重复初始化变量、分配内存、配置外设。一个模块测下来,光初始化代码就占了测试文件的一半。后来被一位老同事点醒:“你这不是在写测试,你是在搬砖。”他给我看了他写的测试夹具,我才恍然大悟——原来测试也能这么优雅。
5.1 什么是测试夹具?
测试夹具(Test Fixture),说白了就是测试用例的“舞台”和“道具”。
你想想看,一个演员上台演戏,需要舞台布景、需要道具、需要灯光音响。测试用例也一样——它需要一个准备好的环境才能运行。这个环境,就是夹具。
具体来说,夹具包含以下几类东西:
- 数据准备:全局变量、结构体、缓冲区的初始值
- 资源分配:malloc出来的内存、打开的文件句柄、GPIO的配置
- 状态预设:状态机的当前状态、定时器的计数值、队列中的元素
- 模拟对象:用来替代真实硬件的桩函数、模拟外设的回调
核心思想:每个测试用例都应该从一个“已知的、干净的”状态开始执行。夹具就是帮你达成这个状态的工具。
我在项目中遇到过最典型的反面教材——有人把测试用例写成这样:
// 反面案例:没有夹具,测试之间互相污染
void test_sensor_read(void) {
sensor_init(); // 每个测试都重复
buffer_clear(); // 每个测试都重复
int val = sensor_read();
assert(val > 0);
}
void test_sensor_timeout(void) {
sensor_init(); // 重复!
buffer_clear(); // 重复!
sensor_set_mode(FAST); // 这个测试需要FAST模式
int val = sensor_read();
assert(val == TIMEOUT);
}
你看,两个测试里都调了 sensor_init() 和 buffer_clear()。如果哪天初始化逻辑变了,你得改多少个地方?而且第二个测试偷偷改了模式,万一影响到第一个测试怎么办?
这就是没有夹具的后果——测试之间互相“串味”。
5.2 setUp 与 tearDown:夹具的“安装”与“拆卸”
几乎所有主流的C单元测试框架(CUnit、Unity、CMocka、Check)都提供了两个特殊的函数:setUp 和 tearDown。
它们的职责非常清晰:
| 函数 | 执行时机 | 职责 |
|---|---|---|
setUp() |
每个测试用例执行之前 | 创建夹具:初始化变量、分配资源、设置状态 |
tearDown() |
每个测试用例执行之后 | 销毁夹具:释放内存、关闭句柄、恢复全局状态 |
我个人的习惯是:setUp 里只做“创建”,tearDown 里只做“销毁”。不要在 setUp 里做测试逻辑,也不要在 tearDown 里做断言。
来看一个用 Unity 框架写的例子:
#include "unity.h"
#include "sensor_driver.h"
static int* buffer = NULL;
static int buffer_size = 128;
// setUp:每个测试前自动调用
void setUp(void) {
buffer = (int*)malloc(buffer_size * sizeof(int));
TEST_ASSERT_NOT_NULL(buffer);
memset(buffer, 0, buffer_size * sizeof(int));
sensor_init();
sensor_set_mode(NORMAL);
}
// tearDown:每个测试后自动调用
void tearDown(void) {
sensor_deinit();
free(buffer);
buffer = NULL;
}
// 测试用例1:正常读取
void test_sensor_read_normal(void) {
int val = sensor_read(buffer, buffer_size);
TEST_ASSERT_EQUAL(SUCCESS, val);
TEST_ASSERT(buffer[0] != 0); // 有数据写入
}
// 测试用例2:超时场景
void test_sensor_read_timeout(void) {
sensor_set_mode(FAST); // 覆盖setUp中的设置
int val = sensor_read(buffer, 0); // 大小为0,触发超时
TEST_ASSERT_EQUAL(TIMEOUT, val);
}
注意看,第二个测试里我改了模式为 FAST。这会不会影响第一个测试?不会。因为每个测试执行前,setUp 都会重新把模式设回 NORMAL。这就是夹具的“隔离”作用。
小技巧:tearDown 里一定要把释放后的指针置为 NULL。我曾经因为忘了这一步,在调试时看到一个“野指针”指向了已经被释放的内存,排查了整整一下午。嗯,血的教训。
5.3 夹具的复用技巧
一个项目里往往有几十上百个测试文件。如果每个文件都写一遍 setUp 和 tearDown,那跟没有夹具也没什么区别。我们需要的是——复用。
我总结了三种复用模式,按推荐程度排序:
模式一:共享夹具头文件
把通用的夹具逻辑提取到一个头文件中,用宏或内联函数实现:
// fixture_common.h
#ifndef FIXTURE_COMMON_H
#define FIXTURE_COMMON_H
#include "unity.h"
#include "system_init.h"
// 全局资源
extern int* g_buffer;
extern int g_buffer_size;
// 内联的setUp逻辑
static inline void common_setUp(void) {
g_buffer = (int*)malloc(256 * sizeof(int));
TEST_ASSERT_NOT_NULL(g_buffer);
system_init();
}
// 内联的tearDown逻辑
static inline void common_tearDown(void) {
system_deinit();
free(g_buffer);
g_buffer = NULL;
}
#endif
然后在每个测试文件里直接调用:
#include "fixture_common.h"
void setUp(void) { common_setUp(); }
void tearDown(void) { common_tearDown(); }
void test_feature_x(void) {
// 直接用g_buffer
g_buffer[0] = 42;
// ... 测试逻辑
}
这种模式的好处是简单直接,坏处是——如果某个测试需要不同的夹具,你得在 setUp 里写条件判断,容易乱。
模式二:分层夹具(推荐)
这是我个人最常用的模式。把夹具分成两层:
- 基础夹具:所有测试都需要的公共资源(如系统时钟、内存池)
- 模块夹具:特定模块需要的资源(如传感器配置、通信缓冲区)
代码结构像这样:
// test_sensor.c
#include "fixture_base.h" // 基础夹具
#include "fixture_sensor.h" // 传感器模块夹具
void setUp(void) {
base_setUp(); // 先初始化基础资源
sensor_setUp(); // 再初始化模块资源
}
void tearDown(void) {
sensor_tearDown(); // 先销毁模块资源
base_tearDown(); // 再销毁基础资源
}
void test_sensor_high_precision(void) {
sensor_set_precision(HIGH);
// ... 测试逻辑
}
注意销毁顺序和创建顺序相反。这是资源管理的黄金法则——后创建的先销毁。
注意:分层夹具里,每个层级的 setUp 和 tearDown 必须成对出现。我曾经见过有人只调了 base_setUp 忘了调 base_tearDown,结果内存泄漏了三天才被发现。嗯,那个项目后来加上了内存泄漏检测工具。
模式三:夹具工厂(高级)
当你的测试需要多种不同的夹具组合时,可以用“工厂模式”。定义一个结构体来描述夹具的配置:
// fixture_factory.h
typedef struct {
bool use_dma;
bool use_interrupt;
int buffer_size;
int sensor_mode;
} FixtureConfig;
// 工厂函数:根据配置创建夹具
void fixture_create(FixtureConfig* cfg);
void fixture_destroy(FixtureConfig* cfg);
// 预定义的常用配置
extern const FixtureConfig FIXTURE_DEFAULT;
extern const FixtureConfig FIXTURE_DMA_ONLY;
extern const FixtureConfig FIXTURE_INTERRUPT_ONLY;
测试用例里这样用:
void test_dma_transfer(void) {
fixture_create((FixtureConfig*)&FIXTURE_DMA_ONLY);
// ... 测试DMA传输
fixture_destroy((FixtureConfig*)&FIXTURE_DMA_ONLY);
}
void test_interrupt_handling(void) {
fixture_create((FixtureConfig*)&FIXTURE_INTERRUPT_ONLY);
// ... 测试中断处理
fixture_destroy((FixtureConfig*)&FIXTURE_INTERRUPT_ONLY);
}
这种模式最灵活,但也最复杂。我一般只在集成测试或系统测试里用,单元测试用前两种模式就够了。
5.4 知识体系总览
说了这么多,咱们用一张图来总结一下测试夹具的核心逻辑:
这张图把咱们今天讲的内容串起来了。你看,夹具不是什么高深的概念,它就是一套“让测试能跑起来、跑得稳、跑得干净”的机制。
最后说一句实在的——写测试夹具,本质上是在为你的代码“搭台子”。台子搭得稳,戏才能唱得好。我见过太多项目,测试代码写了一堆,但因为没有好的夹具,每次跑测试都要手动清理环境,最后大家都不愿意跑测试了。嗯,别让你的项目也走上这条路。
个人建议:从项目第一天就开始用夹具。哪怕只是一个简单的 setUp 里放个 memset,也比没有强。夹具是可以逐步完善的,但“没有夹具”的习惯一旦养成,后面改起来成本极高。
公众号:蓝海资料掘金营,微信deep3321