第20章 测试接口设计:如何为模块设计可测试的接口,Mock对象与桩函数的实现
说实话,很多嵌入式工程师对测试这件事是又爱又恨。爱的是它能帮你提前发现bug,恨的是——硬件还没出来,外设还没连上,你拿什么测?
我早年做的一个车载项目,就吃过这个亏。当时写了一个温度采集模块,逻辑自认为天衣无缝。结果硬件联调那天,一上电就死机。查了两天才发现,是ADC返回的原始值没做边界处理。要是当时能写个桩函数模拟一下异常数据,半小时就能搞定的事,硬是拖了两天。
所以这一章,咱们就聊聊怎么给模块设计可测试的接口,以及Mock对象和桩函数到底怎么落地。
20.1 为什么接口设计要“可测试”?
你想想看,一个模块如果跟硬件绑得太死,比如直接在函数里调用HAL_ADC_Read(),那测试的时候怎么办?没有硬件,这函数就废了。
可测试的接口,说白了就是让模块的依赖关系变得“可替换”。
- 依赖注入:把外部依赖通过参数传进来,而不是在函数内部硬编码。
- 接口抽象:用函数指针或结构体封装操作,测试时换成Mock实现。
- 状态可观测:模块内部的关键状态,留一个“窥视孔”给测试代码。
核心原则:模块不应该知道它正在被测试。它只认接口,不认实现。
20.2 Mock对象与桩函数:到底有什么区别?
这两个词经常被混用,但在我眼里它们分工不同:
| 概念 | 作用 | 典型场景 |
|---|---|---|
| 桩函数(Stub) | 返回预设值,不执行真实逻辑 | 模拟ADC读取、传感器数据 |
| Mock对象 | 记录调用次数、参数,验证行为 | 检查某个回调是否被正确调用 |
举个例子:你写了一个温度采集模块,它依赖一个read_sensor()函数。测试时:
- 桩函数:直接返回25.3,不管你怎么调用。
- Mock对象:不仅返回25.3,还记录你调了几次、传了什么参数。最后断言“必须调用恰好3次”。
我个人习惯是:验证返回值用Stub,验证交互行为用Mock。别混着用,否则测试代码会变得很难维护。
20.3 实战:给一个传感器驱动设计可测试接口
假设我们有一个温湿度传感器模块,真实环境下通过I2C读取。传统写法可能是这样的:
// 传统写法:硬编码依赖
float read_temperature(void) {
uint8_t buf[2];
HAL_I2C_Master_Receive(&hi2c1, 0x40, buf, 2, 100);
return (buf[0] << 8 | buf[1]) * 0.01f;
}
这代码没法测。因为HAL_I2C_Master_Receive是硬件相关的。
怎么改?用函数指针把I2C操作抽象出来:
// 可测试版本:依赖注入
typedef struct {
int (*i2c_read)(uint8_t dev_addr, uint8_t *data, uint16_t len);
} sensor_io_t;
float read_temperature(sensor_io_t *io) {
uint8_t buf[2];
if (io->i2c_read(0x40, buf, 2) != 0) {
return -1.0f; // 错误处理
}
return (buf[0] << 8 | buf[1]) * 0.01f;
}
现在测试的时候,我们可以传入一个桩函数:
// 桩函数:模拟I2C读取
static int stub_i2c_read(uint8_t dev_addr, uint8_t *data, uint16_t len) {
(void)dev_addr;
(void)len;
data[0] = 0x1A; // 模拟温度高字节
data[1] = 0x2B; // 模拟温度低字节
return 0;
}
void test_read_temperature(void) {
sensor_io_t mock_io = { .i2c_read = stub_i2c_read };
float temp = read_temperature(&mock_io);
// 断言 temp 接近 (0x1A << 8 | 0x2B) * 0.01
}
小技巧:桩函数里可以加一个静态计数器,用来模拟“连续读取三次后返回错误”这种边界场景。我在项目中经常用这招来测试重试逻辑。
20.4 更高级的Mock:记录调用行为
有时候光返回固定值不够,你还得验证模块是否按预期调用了外部接口。比如一个看门狗喂狗模块,你得确认它在超时前确实调用了feed_watchdog()。
这时候就需要一个带记录的Mock:
// Mock对象:记录调用次数和参数
static int mock_feed_call_count = 0;
void mock_feed_watchdog(void) {
mock_feed_call_count++;
}
void test_watchdog_feeding(void) {
mock_feed_call_count = 0;
run_main_loop(); // 假设这个函数内部会喂狗
assert(mock_feed_call_count > 0); // 至少喂了一次
}
嗯,这里要注意:每次测试前记得重置计数器。我见过有人忘了重置,结果测试永远通过,因为上一次的计数残留导致断言失效。
20.5 避坑指南:我曾经踩过的三个坑
坑一:桩函数写得太“聪明”
我曾经写过一个桩函数,里面居然包含了真实算法的简化版。结果测试通过了,但真实硬件上跑出来完全不对。因为桩函数和真实实现的bug一模一样。
记住:桩函数只返回预设值,不要复制逻辑。
坑二:Mock对象和Stub混用导致测试不可读
一个测试用例里,既用Mock验证调用次数,又用Stub返回数据,还顺手改了全局状态。最后测试失败了,你根本分不清是哪个环节出了问题。
我的建议是:一个测试用例只验证一件事。要么测返回值,要么测交互行为。
坑三:接口抽象过度
为了可测试,把每个函数都变成函数指针,结果接口结构体比实际代码还复杂。这属于过度设计。
我的经验是:只对“外部依赖”做抽象。比如硬件外设、文件系统、网络协议栈。模块内部的辅助函数,直接调用就好,不用Mock。
20.6 本章知识体系
下面这张图总结了测试接口设计的核心思路:
重要提醒:测试接口设计不是一蹴而就的。我建议你在写模块的第一版代码时,就顺手把接口抽象出来。等代码写完了再回头加测试接口,往往要动大手术,成本高得多。
好了,这一章的内容就到这里。测试接口设计这件事,说白了就是“为未来的自己留一条后路”。你永远不知道明天会不会突然要换硬件、改协议、加功能。接口抽象得好,测试代码就是你的安全网。
公众号:蓝海资料掘金营,微信deep3321