4. 测试桩与模拟对象:手动编写桩函数 vs 自动生成模拟对象
做嵌入式单元测试,你迟早会撞上一个绕不开的问题:被测函数调了外部依赖,比如读传感器、写寄存器、调用另一个模块的接口。这时候怎么办?
我早年做车载控制器测试时,就吃过这个亏。被测代码里调了一个底层CAN驱动,我傻乎乎地连上真实硬件跑测试。结果一次误操作,直接把测试台的电源模块烧了。嗯,从那以后我学乖了——测试桩和模拟对象,必须用起来。
4.1 为什么需要测试桩和模拟对象?
说白了,嵌入式代码的依赖关系太复杂了。你测一个模块,它背后可能链着一串硬件、驱动、OS服务。如果每次都跑全链路,测试就变成了集成测试,速度慢、不稳定、还容易出事故。
测试桩(Stub)和模拟对象(Mock)就是用来替换真实依赖的。它们让你能隔离被测单元,只测你想测的逻辑。
核心原则: 被测单元之外的,都用桩或模拟对象替代。测试只关心“我的代码逻辑对不对”,不关心“别人的代码和硬件好不好”。
4.2 手动编写桩函数
手动桩是最原始、也是最可控的方式。我在早期项目里,几乎全靠手写桩。
举个例子,被测函数调用了 HAL_ADC_GetValue(),你写一个桩:
// 被测模块:adc_reader.c
uint32_t read_adc_average(void) {
uint32_t sum = 0;
for (int i = 0; i < 10; i++) {
sum += HAL_ADC_GetValue();
}
return sum / 10;
}
// 测试桩:stub_adc.c
static uint32_t stub_value = 0;
uint32_t HAL_ADC_GetValue(void) {
return stub_value;
}
void set_adc_stub_value(uint32_t val) {
stub_value = val;
}
手动桩的好处很明显:
- 完全可控——你想让它返回什么就返回什么
- 无外部依赖——一个.c文件搞定,不依赖任何框架
- 调试方便——出问题了直接看桩代码,逻辑一目了然
但缺点也突出:
- 重复劳动——每个依赖都要写一套桩,项目大了能写几百个
- 维护成本高——接口变了,所有桩都得跟着改
- 功能有限——很难做复杂的调用次数校验、参数捕获
我的经验: 手动桩适合接口稳定、数量少(10个以内)的场景。比如你只测一个模块,它只依赖2-3个底层函数,手写桩是最快的。
4.3 自动生成模拟对象
当项目规模上来后,手动桩就扛不住了。我记得有个项目,被测模块依赖了30多个外部接口,手写桩文件加起来快2000行。每次接口变更,改桩改到崩溃。
这时候就需要自动生成模拟对象。常用的工具有:
| 工具/框架 | 语言 | 特点 |
|---|---|---|
| CMock | C | 基于Unity测试框架,自动生成Mock函数 |
| Fake Function Framework (FFF) | C | 轻量级,宏定义实现,无需代码生成 |
| CppUMock | C++ | CppUTest配套,支持C和C++ |
| Google Mock | C++ | 功能强大,但需要C++编译器 |
以FFF为例,它用宏定义自动生成模拟对象:
// 使用FFF框架
#include "fff.h"
// 声明要模拟的函数
FAKE_VALUE_FUNC(uint32_t, HAL_ADC_GetValue);
// 测试代码
void test_read_adc_average(void) {
// 设置返回值序列
HAL_ADC_GetValue_fake.return_val_seq_len = 10;
HAL_ADC_GetValue_fake.return_val_seq = (uint32_t[]){100, 102, 98, 101, 99, 100, 101, 99, 100, 102};
// 调用被测函数
uint32_t result = read_adc_average();
// 验证结果
TEST_ASSERT_EQUAL(100, result);
// 验证调用次数
TEST_ASSERT_EQUAL(10, HAL_ADC_GetValue_fake.call_count);
}
自动生成的优势:
- 效率高——一行宏定义就生成一个完整的模拟对象
- 功能丰富——自动记录调用次数、参数、返回值序列
- 维护简单——接口变了,重新生成即可
但也不是没有代价:
- 学习成本——团队成员需要掌握框架用法
- 调试困难——出问题时,宏展开后的代码很难跟踪
- 资源开销——自动生成的代码可能比手写桩大
我曾经踩过的坑: 在资源受限的MCU上跑单元测试,用了CMock自动生成大量模拟对象,结果代码体积膨胀到Flash装不下。后来发现是CMock为每个模拟函数生成了完整的参数校验和调用记录代码。解决方案是:对性能敏感的桩用手写,对逻辑复杂的桩用自动生成。
4.4 如何选择?
你想想看,到底用手写还是自动生成?我个人的决策框架是这样的:
- 接口数量 < 5,且稳定不变 → 手写桩,最快最轻
- 接口数量 5-20,或经常变更 → 自动生成,用FFF或CMock
- 需要校验调用顺序、参数匹配 → 自动生成,用Google Mock或CppUMock
- 资源极度受限(ROM < 64KB) → 手写桩,控制代码体积
下面这张图展示了两种方式的适用场景:
4.5 混合策略:我推荐的做法
在实际项目中,我很少只用一种方式。我的习惯是:
- 底层硬件抽象层(HAL):手写桩。因为这些接口极少变化,而且需要精确控制返回值来模拟硬件异常。
- 模块间调用:自动生成模拟对象。因为这些接口经常变更,而且需要校验调用顺序和参数。
- OS服务(任务调度、信号量等):手写桩+少量宏辅助。OS接口稳定,但行为复杂,手写更可控。
一个小技巧: 在项目初期,先用自动生成框架快速搭建测试骨架。等接口稳定后,再把性能热点的手动替换成手写桩。这样既保证了开发效率,又控制了最终代码体积。
4.6 避坑指南
最后,分享几个我亲身踩过的坑:
- 坑1:桩函数没有模拟错误路径。 我曾经只让桩返回正常值,结果被测代码里的错误处理分支从来没被测试覆盖过。上线后硬件异常,代码直接崩溃。现在我的桩一定会模拟至少一种异常情况。
- 坑2:自动生成模拟对象时,忘了重置状态。 FFF框架的模拟对象默认会累积调用次数。如果测试用例之间不重置,后面的用例会被前面的影响。我现在每个测试用例开始前都会调用
RESET_FAKE()。 - 坑3:手写桩和真实接口签名不一致。 有一次重构接口,改了参数类型,但忘了更新桩文件。编译通过了(因为C语言隐式转换),但测试结果全错。现在我会在桩文件里加编译期断言,确保签名匹配。
嗯,测试桩和模拟对象这件事,说白了就是在可控性和效率之间找平衡。没有银弹,但理解了各自的优劣,你就能根据项目实际情况做出合理选择。