4. 单元测试核心概念
做嵌入式单元测试,最头疼的是什么?
不是被测代码本身有多复杂,而是它依赖的东西太多——硬件寄存器、外设驱动、RTOS 的 API、甚至另一块芯片的通信协议。你想想看,如果测一个温度传感器驱动,每次跑测试都得真的接个传感器上去,那还叫单元测试吗?
所以,我们需要「替身」。在测试领域,这些替身有个专门的名字——测试替身(Test Double)。而其中最常用的两种,就是桩函数(Stub)和模拟对象(Mock)。
一句话区分:桩函数负责「给答案」,模拟对象负责「查行为」。前者关注返回值,后者关注调用过程。
4.1 什么是桩函数(Stub)?
桩函数,说白了就是一个「假函数」。它替换掉真实依赖,返回你预先设定好的数据。
我在项目中遇到过这样一个场景:测一个电池电量计算模块,它内部调用了 adc_read() 来获取电压值。真实硬件上,ADC 读回来的值受温度、噪声影响,每次都不一样。这怎么测?
我的做法很简单——写个桩函数:
// 真实函数(硬件相关)
int adc_read(uint8_t channel) {
// 操作ADC寄存器,等待转换完成...
return HAL_ADC_GetValue(&hadc);
}
// 桩函数(测试用)
int adc_read(uint8_t channel) {
// 不管channel是什么,都返回固定值
return 2048; // 对应3.3V输入的一半
}
这样,被测模块就以为 ADC 读到了 2048,然后去算电量百分比。我可以控制这个返回值,验证不同电压下的逻辑是否正确。
桩函数的核心特征:
- 返回值由测试用例硬编码或通过参数传入
- 不关心被调用了多少次、什么顺序
- 通常没有副作用(不写文件、不发消息)
- 适合用来隔离「查询型」依赖(读传感器、读配置、读状态)
4.2 什么是模拟对象(Mock)?
模拟对象比桩函数「高级」一点。它不仅提供返回值,还会记录调用信息——谁调了它、传了什么参数、调了几次、顺序对不对。
为什么会需要这个?
我记得有一次测一个通信协议栈,上层调用 send_packet() 发送数据,底层驱动应该先发一个同步头,再发数据包,最后发校验。如果底层驱动没按这个顺序来,整个通信就会乱掉。这时候,光靠桩函数返回「发送成功」是不够的,我得知道它到底怎么发的。
// 模拟对象示例(使用CppUMock风格)
TEST(CommTest, ShouldSendSyncThenDataThenChecksum) {
// 设定预期:先调send_byte(0xAA),再调send_byte(0x55),最后调send_byte(0x01)
mock().expectOneCall("send_byte").withIntParameter("data", 0xAA);
mock().expectOneCall("send_byte").withIntParameter("data", 0x55);
mock().expectOneCall("send_byte").withIntParameter("data", checksum);
// 执行被测函数
send_packet(test_data);
// 验证:所有预期调用都已发生(CppUMock在析构时自动验证)
mock().checkExpectations();
}
看到区别了吗?模拟对象在测试开始前就「预定」了行为,测试结束后再「对账」。如果实际调用和预期不符,测试就失败。
注意:模拟对象容易让测试变得脆弱。如果实现细节稍微变一下(比如优化时合并了两次发送),测试就挂了。我个人建议:只在验证「交互顺序」和「调用次数」很重要时才用 Mock,否则用 Stub 就够了。
4.3 桩 vs 模拟:一张表说清楚
| 维度 | 桩函数(Stub) | 模拟对象(Mock) |
|---|---|---|
| 关注点 | 返回值 | 调用行为 |
| 验证方式 | 断言返回值 | 验证调用次数、参数、顺序 |
| 状态管理 | 通常无状态 | 可记录调用历史 |
| 适用场景 | 读取传感器、获取配置、查询状态 | 发送命令、触发事件、协议交互 |
| 实现复杂度 | 低(几行代码) | 中高(需要框架或手动记录) |
| 测试脆弱性 | 低 | 中高(实现细节变化易挂) |
4.4 手动编写桩 vs 自动生成
好,现在问题来了:这些桩和模拟,是手写还是用工具生成?
两种我都用过,各有各的坑。
4.4.1 手动编写桩
早期做嵌入式测试,我基本都是手写。比如给一个 I2C 驱动写桩:
// 手动桩:i2c_stub.c
static uint8_t mock_registers[256];
static bool write_protected = false;
int i2c_write(uint8_t dev_addr, uint8_t reg, uint8_t *data, uint16_t len) {
// 只响应特定设备地址
if (dev_addr != 0x50) return -1;
// 模拟写保护
if (write_protected && reg >= 0x10) return -2;
memcpy(&mock_registers[reg], data, len);
return 0;
}
int i2c_read(uint8_t dev_addr, uint8_t reg, uint8_t *data, uint16_t len) {
if (dev_addr != 0x50) return -1;
memcpy(data, &mock_registers[reg], len);
return 0;
}
// 测试辅助函数
void i2c_stub_set_register(uint8_t reg, uint8_t value) {
mock_registers[reg] = value;
}
void i2c_stub_set_write_protect(bool enable) {
write_protected = enable;
}
优点:完全可控,没有黑盒,想怎么模拟就怎么模拟。
缺点:写起来累。一个稍微复杂点的外设,桩代码可能比被测代码还长。而且每个项目都得重新写一遍。
我的经验:手写桩适合「简单且稳定」的依赖。比如一个 GPIO 读写函数,手写几行就够了。但如果是 SPI Flash 驱动、文件系统这类复杂接口,手写桩会让人崩溃。
4.4.2 自动生成桩
后来我开始用工具。C 语言里比较成熟的有 CppUMock、CMock(配合 Unity 测试框架)、Fake Function Framework (FFF)。
以 FFF 为例,自动生成桩只需要几行声明:
// 使用FFF自动生成桩
#include "fff.h"
// 声明要模拟的函数
FAKE_VALUE_FUNC(int, adc_read, uint8_t);
FAKE_VOID_FUNC(gpio_set_pin, uint8_t, bool);
FAKE_VALUE_FUNC(int, i2c_write, uint8_t, uint8_t, uint8_t*, uint16_t);
TEST(AdcTest, ShouldReturnExpectedValue) {
// 设置返回值
adc_read_fake.return_val = 2048;
// 执行测试
int result = calculate_battery_level();
// 断言
ASSERT_EQ(result, 50);
// 验证调用次数
ASSERT_EQ(adc_read_fake.call_count, 1);
}
你看,三行宏定义就生成了三个完整的桩函数。每个桩还自动带了 call_count、arg_history 等记录功能。
| 对比维度 | 手动编写 | 自动生成 |
|---|---|---|
| 开发速度 | 慢(每个函数都要手写) | 快(宏或脚本生成) |
| 灵活性 | 高(可以模拟复杂行为) | 中(受限于框架能力) |
| 维护成本 | 高(接口变了要手动改桩) | 低(重新生成即可) |
| 代码可读性 | 好(桩逻辑清晰可见) | 中(宏展开后不易调试) |
| 适合场景 | 简单接口、特殊行为模拟 | 大量接口、频繁变更的依赖 |
4.5 知识体系:模拟与桩的核心逻辑
下面这张图,是我自己总结的「测试替身选择决策树」。每次做单元测试前,我都会过一遍:
4.6 避坑指南:我曾经踩过的坑
做了这么多年测试,在桩和模拟上栽过的跟头不少。分享几个典型的:
- 桩函数太「聪明」:我曾经写过一个桩,里面塞了完整的业务逻辑。结果被测代码有 bug,桩也有 bug,两个 bug 互相抵消,测试全绿。上线就崩。后来我规定:桩函数里最多三行代码,超过就说明设计有问题。
- Mock 验证过度:有一次我 Mock 了一个日志打印函数,连调用了多少次、传了什么字符串都验证。后来重构时把两条日志合并成一条,测试挂了。其实日志内容对不对根本不重要。记住:只验证你关心的行为。
- 自动生成桩的链接问题:用 FFF 时,如果被测文件里
#include了真实头文件,而链接器又先找到了真实函数,桩就不会生效。我习惯在测试文件里用#define把真实函数名替换掉,或者用链接器的--wrap选项。
我的个人建议:
- 新手从手动 Stub 开始,先理解「替身」的概念
- 等测试多了,引入 FFF 或 CMock 提升效率
- 只在验证「交互协议」和「调用顺序」时用 Mock
- 永远不要让桩函数包含业务逻辑
嗯,关于桩和模拟,核心就是这些。记住一个原则:测试替身是为了让被测代码「孤立」出来,不是为了模拟整个世界。你替掉的依赖越少,测试的可信度就越高。