第1章:函数与测试——单元测试中的函数桩与模拟
做嵌入式开发这么多年,我踩过最大的坑之一,就是代码写完了才发现没法测。
你想想看,一个函数依赖硬件寄存器、依赖另一个模块的状态、依赖某个全局变量……你单独拎出来跑,它根本跑不起来。这时候你怎么办?
嗯,这就是我们今天要聊的——函数桩(Stub)和模拟(Mock)。
1.1 为什么需要桩和模拟?
说白了,单元测试的目标是「测一个函数,而不是测整个系统」。但现实是,大部分函数都不是孤岛——它会调用别的函数、读写硬件、或者依赖某个外部状态。
我在项目中遇到过这样一个场景:一个温度采集函数,内部调用了 I2C_Read()。如果直接测,你得接上传感器、上电、等温度变化……这哪是单元测试?这明明是集成测试。
所以我们需要替身——用桩函数代替真实的 I2C 读取,用模拟对象代替复杂的依赖关系。
1.2 函数桩(Stub)——最简单的替身
桩函数是最基础的测试替身。它不做任何实际工作,只返回一个预设的值。
举个例子:
// 真实函数
int Get_Temperature(void) {
return I2C_Read(0x48); // 读传感器
}
// 桩函数
int Get_Temperature(void) {
return 25; // 固定返回25度
}
你看,桩函数就这么简单。它存在的意义是:让被测代码能编译通过、能跑起来。
我个人习惯在测试文件中这样组织桩函数:
// test_stub.c
#include "stub.h"
int Stub_GetTemperature(void) {
return 25;
}
int Stub_GetPressure(void) {
return 100;
}
// 用一个结构体管理所有桩的返回值
typedef struct {
int temperature;
int pressure;
int humidity;
} Stub_Config;
Stub_Config g_stub_cfg = {25, 100, 60};
这样做的好处是:你可以在测试用例里随时修改桩的返回值,不用重新编译整个文件。
Stub_ 或 Fake_,一眼就能看出来这是测试代码,不是生产代码。
1.3 模拟(Mock)——更聪明的替身
桩函数只能返回固定值,但有时候我们需要验证「这个函数有没有被调用?」「调了几次?」「传了什么参数?」。
这就是模拟(Mock)的用武之地。
我曾经在一个通信协议栈的项目里,需要测试一个数据发送函数。它内部会调用 UART_SendByte()。我想确认的是:发送函数是否按正确的顺序调用了 UART 发送?
用 Mock 可以这样写:
// mock_uart.c
static int g_call_count = 0;
static uint8_t g_last_byte = 0;
void Mock_UART_SendByte(uint8_t data) {
g_call_count++;
g_last_byte = data;
}
// 测试用例
void test_send_packet(void) {
uint8_t packet[] = {0xAA, 0xBB, 0xCC};
SendPacket(packet, 3);
// 验证:调用了3次
ASSERT_EQ(g_call_count, 3);
// 验证:最后一次发送的是0xCC
ASSERT_EQ(g_last_byte, 0xCC);
}
看到了吗?Mock 不光能返回值,还能记录行为。这是它和 Stub 最大的区别。
| 特性 | Stub(桩) | Mock(模拟) |
|---|---|---|
| 返回值 | 固定预设值 | 可预设,也可动态 |
| 记录调用 | 不记录 | 记录次数、参数、顺序 |
| 验证行为 | 不能 | 可以验证「是否被调用」 |
| 适用场景 | 依赖函数必须返回一个值 | 需要验证交互行为 |
1.4 测试驱动开发中的函数设计
测试驱动开发(TDD)对函数设计提出了更高的要求。我刚开始做 TDD 时,最大的感受是:函数必须「可测试」。
什么叫可测试?说白了就是:
- 函数不要依赖全局变量(或者至少能注入)
- 函数不要直接调用硬件(通过接口抽象)
- 函数的输入输出要清晰
举个例子,一个不好的设计:
// 不可测试
void ProcessData(void) {
extern int g_sensor_value; // 全局变量
if (g_sensor_value > 100) {
UART_SendString("ALARM"); // 直接调用硬件
}
}
改成可测试的设计:
// 可测试
void ProcessData(int sensor_value, void (*send_func)(const char*)) {
if (sensor_value > 100) {
send_func("ALARM");
}
}
你看,现在我可以传一个桩函数进去,也可以传一个模拟函数进去。测试变得非常灵活。
- 依赖注入:把外部依赖作为参数传进来
- 单一职责:一个函数只做一件事
- 接口抽象:用函数指针或接口结构体隔离硬件
1.5 知识体系总览
下面这张图,是我自己整理的一个函数测试知识体系。它把 Stub、Mock 和 TDD 的关系串在了一起:
1.6 避坑指南
最后,分享几个我踩过的坑:
- 不要用 Mock 模拟所有东西。 我曾经在一个项目里,把 80% 的测试代码都写成了 Mock,结果每次改接口都要改几十个测试文件。后来我学乖了——只 Mock 外部依赖,内部函数用真实实现。
- 桩函数要简单。 我见过有人把桩函数写得比真实函数还复杂,里面加了一堆条件判断。这完全违背了桩的初衷。桩就是「假人」,越简单越好。
- 测试代码也是代码。 很多人写完测试就不管了,结果测试代码比生产代码还难维护。我个人习惯:测试代码和生产代码一样,要 review、要重构、要写注释。
#ifdef UNIT_TEST #define I2C_Read Stub_I2C_Read#endif这样不用改生产代码,测试时自动替换。
好了,关于函数桩和模拟,今天就聊到这里。记住一句话:测试不是为了证明代码没错,而是为了让你有信心改代码。Stub 和 Mock 就是给你这份信心的工具。
公众号:蓝海资料掘金营,微信 deep3321