7、模拟与桩函数:为什么需要模拟、手写桩函数、链接时替换
单元测试里有个绕不开的话题——依赖。
你写的函数,很少是孤岛。它可能要调用别的模块,可能要读写硬件寄存器,可能要访问文件系统。这些外部依赖,在单元测试环境里往往跑不起来。怎么办?
答案就是:模拟(Mock)和桩函数(Stub)。
为什么需要模拟?
说白了,模拟就是为了隔离。我只想测我写的这个函数逻辑对不对,不想管它调用的那个函数有没有bug,更不想让测试跑到硬件上去。
我遇到过最典型的场景:测一个温度采集模块。它内部调用了read_adc(),这个函数会去操作真实的ADC寄存器。在PC上做单元测试时,根本没有这个硬件。如果不模拟,测试根本跑不起来。
模拟能带来几个好处:
- 可控性:你可以让桩函数返回任何你想要的值,比如正常值、边界值、错误码。
- 可重复性:每次测试结果都一样,不会因为硬件状态不同而飘忽不定。
- 速度:模拟的调用是纳秒级的,比真实硬件快几个数量级。
- 覆盖异常路径:你想测“ADC读取失败”这个分支,真实硬件很难触发。但桩函数直接返回-1,轻松搞定。
手写桩函数
最原始也最灵活的方式,就是自己写桩函数。
假设被测函数长这样:
// 被测模块:temperature.c
#include "adc.h"
float get_temperature(void) {
int raw = read_adc(ADC_CHANNEL_1);
if (raw < 0) {
return -1.0f; // 错误
}
return (float)raw * 0.125f - 50.0f;
}
它依赖了adc.h里的read_adc()。在测试文件里,我直接写一个桩函数:
// test_temperature.c
#include "temperature.h"
// 桩函数:模拟ADC读取
static int mock_adc_value = 0;
int read_adc(int channel) {
// 忽略channel参数,直接返回预设值
return mock_adc_value;
}
void test_get_temperature_normal(void) {
mock_adc_value = 400; // 模拟正常值
float temp = get_temperature();
// 期望:400 * 0.125 - 50 = 0.0
assert(temp == 0.0f);
}
void test_get_temperature_error(void) {
mock_adc_value = -1; // 模拟错误
float temp = get_temperature();
assert(temp == -1.0f);
}
嗯,这里要注意:桩函数必须和原函数有完全相同的签名。返回值、参数类型、参数个数,一个都不能差。否则链接时会报错,或者更糟——静默地调用到错误函数。
链接时替换
手写桩函数虽然简单,但有个问题:你得把桩函数和被测代码编译到一起,并且确保链接器优先找到你的桩函数,而不是真正的实现。
这就是链接时替换的技巧。
具体做法分三步:
- 编译被测模块:正常编译
temperature.c,但不链接。 - 编译桩函数:把
test_temperature.c(里面包含了桩函数)也编译成目标文件。 - 链接测试程序:把两个目标文件链接在一起。链接器看到
read_adc有两个定义(一个在adc.c,一个在桩函数里),它会优先选择第一个找到的。只要我们把桩函数的目标文件放在前面,它就赢了。
用命令行演示一下:
# 编译被测模块
gcc -c temperature.c -o temperature.o
# 编译测试文件(包含桩函数)
gcc -c test_temperature.c -o test_temperature.o
# 链接:桩函数目标文件在前,替换掉真正的adc.o
gcc test_temperature.o temperature.o -o test_runner -lcunit
你看,根本没有链接adc.o。链接器找不到真正的read_adc?不,它找到了桩函数里的那个,完美替换。
read_adc,测试直接去操作硬件了,把开发板上的ADC寄存器写乱了。从那以后,我每次都在Makefile里显式控制链接顺序,绝不含糊。
三种模拟方式的对比
| 方式 | 优点 | 缺点 | 适用场景 |
|---|---|---|---|
| 手写桩函数 | 完全可控,无外部依赖 | 每个依赖都要手写,工作量大 | 依赖少,或需要精细控制返回值 |
| 链接时替换 | 无需修改源代码,纯编译技巧 | 需要管理链接顺序,容易出错 | 已有现成代码,不想改动 |
| Mock框架 | 自动化生成桩函数,功能强大 | 学习成本高,可能引入框架bug | 大型项目,依赖多 |
我个人更推荐手写桩函数 + 链接时替换的组合。你想想看,嵌入式项目里依赖通常就那么几个——硬件抽象层、外设驱动、OS接口。手写几个桩函数花不了多少时间,但换来的是完全透明的测试逻辑。
知识体系总览
下面这张图,把本章的核心逻辑串起来了:
说白了,模拟和桩函数就是给被测函数搭一个安全可控的测试环境。你不需要真的去操作硬件,也不需要真的去读写文件。你只需要告诉桩函数:“这次你给我返回0,下次你给我返回-1。” 然后看被测函数怎么处理这些返回值。
我在项目中见过不少同事,一开始觉得手写桩函数麻烦,跑去用复杂的Mock框架。结果框架配置比测试代码还长,出了问题都不知道是框架的bug还是自己的bug。后来大家都回归到最朴素的方式——手写桩函数,链接时替换。简单、直接、可控。
#ifdef UNIT_TEST,只在测试编译时启用桩函数,正常编译时还是用真实实现。这样一份代码,两种用途,不会乱。
嗯,关于模拟和桩函数,核心就这些。记住:隔离依赖,控制输入,验证输出。能做到这三点,你的单元测试就已经成功了一大半。
公众号:蓝海资料掘金营,微信deep3321