第五章:测试夹具——测试用例的“舞台”与“道具”

各位同学,咱们今天聊一个特别实在的话题——测试夹具。

说实话,我刚入行那会儿,根本不知道什么叫“夹具”。那时候写单元测试,就是傻乎乎地在每个测试函数里重复初始化变量、分配内存、配置外设。一个模块测下来,光初始化代码就占了测试文件的一半。后来被一位老同事点醒:“你这不是在写测试,你是在搬砖。”他给我看了他写的测试夹具,我才恍然大悟——原来测试也能这么优雅。

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)都提供了两个特殊的函数:setUptearDown

它们的职责非常清晰:

函数 执行时机 职责
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 夹具的复用技巧

一个项目里往往有几十上百个测试文件。如果每个文件都写一遍 setUptearDown,那跟没有夹具也没什么区别。我们需要的是——复用

我总结了三种复用模式,按推荐程度排序:

模式一:共享夹具头文件

把通用的夹具逻辑提取到一个头文件中,用宏或内联函数实现:

// 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 与 tearDown setUp:创建夹具(测试前) tearDown:销毁夹具(测试后) 保证测试隔离,互不污染 夹具复用技巧 模式一:共享头文件 模式二:分层夹具 ★推荐 模式三:夹具工厂(高级) 核心目标:可重复 · 可隔离 · 可维护

这张图把咱们今天讲的内容串起来了。你看,夹具不是什么高深的概念,它就是一套“让测试能跑起来、跑得稳、跑得干净”的机制。

最后说一句实在的——写测试夹具,本质上是在为你的代码“搭台子”。台子搭得稳,戏才能唱得好。我见过太多项目,测试代码写了一堆,但因为没有好的夹具,每次跑测试都要手动清理环境,最后大家都不愿意跑测试了。嗯,别让你的项目也走上这条路。

个人建议:从项目第一天就开始用夹具。哪怕只是一个简单的 setUp 里放个 memset,也比没有强。夹具是可以逐步完善的,但“没有夹具”的习惯一旦养成,后面改起来成本极高。


公众号:蓝海资料掘金营,微信deep3321