模块化编程中的测试策略:单元测试框架选择、用例编写、Mock与覆盖率
说实话,很多嵌入式工程师对“测试”这件事,态度挺微妙的。要么觉得“我代码写得稳,不用测”,要么就是“测了也白测,硬件环境太复杂”。这两种心态我都经历过。早些年我带一个车载通信项目,底层驱动改了三次,每次都要烧录、上电、看波形,折腾两天才发现是某个位域赋值反了。从那以后,我彻底服了——没有单元测试的模块化,就是空中楼阁。
今天咱们就聊聊嵌入式C语言里的测试策略。我会把框架选择、用例编写、Mock对象、覆盖率这几个硬骨头,一个一个啃下来。
单元测试框架怎么选?CUnit vs Unity
嵌入式圈子里,CUnit和Unity是两大主流。我个人的习惯是:资源受限选Unity,PC端开发选CUnit。为什么?
| 特性 | CUnit | Unity |
|---|---|---|
| 运行环境 | 依赖标准C库,适合PC | 纯C,无依赖,适合MCU |
| 安装方式 | 需编译安装库 | 直接拷贝源文件 |
| 断言种类 | 丰富(30+) | 基础(15+) |
| 测试套件 | 支持嵌套 | 线性结构 |
| 输出格式 | XML/Text | Text/自定义 |
我记得有一次给一个STM32项目做单元测试,硬件资源只有2KB RAM。CUnit根本跑不起来,光是初始化就要占掉1KB多。换成Unity,整个框架才几十KB,编译后几乎不占额外RAM。说白了,选框架就是选“适配度”。
测试用例怎么写?从“测功能”到“测边界”
很多新手写测试用例,只测“正常情况”。比如一个计算校验和的函数,只传有效数据。这其实不够。我见过一个案例:某工程师写了一个CRC16函数,测试时所有数据都通过,结果现场发现当输入长度为0时,函数返回了0xFFFF而不是0x0000——这就是边界没测。
写测试用例,我总结了一个“三三制”:
- 三分之一测正常流程:验证功能正确
- 三分之一测边界条件:空指针、零长度、最大值、最小值
- 三分之一测异常路径:错误码、超时、资源不足
举个例子,一个简单的环形缓冲区写入函数:
// 被测函数
int ringbuf_write(ringbuf_t *rb, uint8_t data);
// 测试用例(Unity风格)
void test_ringbuf_write_normal(void) {
ringbuf_t rb;
ringbuf_init(&rb, buf, 16);
TEST_ASSERT_EQUAL(0, ringbuf_write(&rb, 0xAA));
TEST_ASSERT_EQUAL(0xAA, ringbuf_read(&rb));
}
void test_ringbuf_write_full(void) {
ringbuf_t rb;
ringbuf_init(&rb, buf, 4);
for (int i = 0; i < 4; i++) ringbuf_write(&rb, i);
TEST_ASSERT_EQUAL(-1, ringbuf_write(&rb, 0xFF)); // 满时返回-1
}
void test_ringbuf_write_null(void) {
TEST_ASSERT_EQUAL(-2, ringbuf_write(NULL, 0xAA));
}
你看,三个用例覆盖了正常、满、空指针三种场景。我曾经在一个项目中漏掉了“满缓冲区”的测试,结果产品在连续接收数据时直接覆盖了旧数据,导致丢包。嗯,从那以后我再也不敢偷懒了。
Mock对象:把“依赖”变成“可控”
模块化编程里,最头疼的就是模块间的耦合。比如你的温度采集模块依赖I2C驱动,但I2C驱动还没写好,怎么办?用Mock。
Mock说白了就是造一个假的依赖模块,让它返回你期望的值。我习惯用Unity自带的Mock框架(CMock),或者自己手写简单的Mock。
举个例子,假设你的温度模块调用了一个底层函数 i2c_read_register():
// 原始依赖
int i2c_read_register(uint8_t dev_addr, uint8_t reg_addr);
// Mock实现(手写)
static int mock_i2c_read_register(uint8_t dev_addr, uint8_t reg_addr) {
// 记录调用参数
static uint8_t last_dev, last_reg;
last_dev = dev_addr;
last_reg = reg_addr;
// 返回预设值
return mock_return_value;
}
// 测试时替换
#define i2c_read_register mock_i2c_read_register
这样做的好处是:你可以在不依赖硬件的情况下,测试温度模块的所有逻辑。比如模拟I2C返回0xFF(传感器故障),看温度模块是否进入错误处理流程。
测试覆盖率:别被数字骗了
覆盖率工具(如gcov、LCOV)能告诉你哪些代码被执行了。但我要泼一盆冷水:100%行覆盖率不代表代码没问题。
我见过一个项目,覆盖率报告显示95%,但现场还是出了bug。为什么?因为覆盖率只统计“执行了哪一行”,不统计“执行了哪些路径”。比如一个if-else语句,你只测了if分支,else分支没测,但覆盖率工具可能因为行号重叠而显示“已覆盖”。
我个人建议关注三个指标:
- 函数覆盖率:每个函数至少被调用一次
- 分支覆盖率:每个if/else、switch/case都走到
- MC/DC覆盖率:每个条件独立影响结果(安全关键项目必做)
在嵌入式项目中,我通常要求函数覆盖率≥90%,分支覆盖率≥80%。低于这个数,说明测试用例没写全。
一张图看懂测试策略
下面这张SVG图,是我自己总结的测试策略流程。从框架选择到覆盖率分析,一条线串起来:
这张图的核心逻辑是:先选框架,再写用例,Mock掉依赖,然后执行并分析覆盖率。如果覆盖率不达标,就回去补用例,直到达标为止。
避坑指南:我踩过的三个坑
最后分享几个实战教训,都是真金白银换来的:
- 坑一:Mock了所有东西,结果测试通过,集成时崩溃。 原因:Mock的返回值太理想,没模拟硬件延迟。后来我加了一个“延迟模拟”参数,让Mock可以随机返回超时。
- 坑二:覆盖率工具在MCU上跑不起来。 有些MCU没有文件系统,gcov的输出没法保存。我的解决方案是:在PC上用模拟器(如QEMU)跑测试,覆盖率在PC端生成。
- 坑三:测试用例太多,维护成本爆炸。 我曾经一个模块写了200个用例,结果每次需求变更都要改50个。后来我学会了“按接口分组”,只测公开接口,内部函数通过静态分析保证质量。
好了,关于单元测试的策略就聊到这儿。记住:测试不是负担,是保险。你花在测试上的每一分钟,都会在调试时加倍还给你。