4、单元测试策略:框架选择、桩驱动与覆盖率分析
单元测试,说白了就是给代码做「体检」。在医疗显微镜这种嵌入式系统里,一个函数出问题,可能整台设备就罢工了。我这些年做下来,发现单元测试做得好不好,直接决定了后续集成测试的血压高不高。
4.1 单元测试框架选择:CUnit vs Unity
嵌入式C语言的单元测试框架,我主要用过两个:CUnit和Unity。它们各有千秋,我分别说说。
CUnit
CUnit是老牌框架,功能全面。它支持测试套件、断言、测试报告生成。我个人习惯在PC端开发环境里用CUnit,因为它依赖标准C库,在Linux或Windows上跑起来很方便。
优点:
- 功能丰富,文档齐全
- 支持多种输出格式(XML、文本)
- 适合大型项目
缺点:
- 体积较大,不适合资源受限的MCU
- 需要操作系统支持
Unity
Unity是专门为嵌入式设计的轻量级框架。它只有几个文件,纯C语言实现,不依赖标准库。我在做STM32项目时,经常把Unity直接集成到固件里,在目标板上跑测试。
优点:
- 极简,核心代码不到2000行
- 可移植性强,裸机也能跑
- 配合CMock可以自动生成桩函数
缺点:
- 功能相对简单
- 报告格式需要自己定制
我的选择建议:
如果项目在PC端开发,用CUnit。如果要在MCU上直接跑测试,用Unity。我遇到过有人非要在Cortex-M0上跑CUnit,结果编译出来代码比Flash还大……嗯,后来他换Unity了。
4.2 桩函数与驱动函数设计
单元测试里最头疼的,就是处理外部依赖。比如你的函数调用了ADC读取、I2C通信、或者某个硬件寄存器。这时候就需要桩函数和驱动函数。
桩函数(Stub)
桩函数是「假的」被调用函数。它不执行真实操作,而是返回预设值。说白了,就是骗你的被测函数,让它以为外部环境正常。
// 真实函数
int read_temperature_sensor(void) {
// 实际读取硬件
return i2c_read(0x48, 0x00);
}
// 桩函数
int read_temperature_sensor(void) {
// 返回固定值,用于测试
return 25; // 模拟25°C
}
设计原则:
- 桩函数要简单,不要有逻辑
- 能模拟正常和异常情况
- 最好能记录调用次数和参数
小技巧:
我习惯在桩函数里加一个全局计数器。比如 stub_call_count++,这样就能知道被测函数调用了多少次外部接口。有一次我发现一个函数调了3次ADC,但设计文档说只调1次……嗯,bug就这么抓到了。
驱动函数(Driver)
驱动函数是「假的」调用者。它模拟上层模块来调用你的被测函数。比如你要测试一个温度转换函数,驱动函数就负责传入不同的ADC值,然后检查输出。
// 驱动函数示例
void test_temperature_conversion(void) {
int adc_value = 512; // 模拟ADC读数
int expected_temp = 25;
int actual_temp = convert_adc_to_temp(adc_value);
TEST_ASSERT_EQUAL_INT(expected_temp, actual_temp);
}
注意:
驱动函数不要写得太复杂。它只是测试的「启动器」,不是被测逻辑的一部分。我曾经见过有人把业务逻辑写进驱动函数里,结果测试通过了,但实际代码全是bug……那场面,真是哭笑不得。
4.3 代码覆盖率分析
覆盖率是衡量测试质量的尺子。但记住,100%覆盖率不代表没bug。我见过覆盖率100%但功能全挂的项目……你想想看,那是怎么做到的?测试代码本身就有bug。
语句覆盖(Statement Coverage)
最简单的一种。每个语句至少执行一次。说白了,就是你的代码每一行都被跑过。
if (temp > 100) {
alarm_on(); // 这条语句需要被覆盖
} else {
alarm_off(); // 这条语句也需要被覆盖
}
要求: 医疗设备通常要求语句覆盖率达到100%。
分支覆盖(Branch Coverage)
每个判断的真假分支都要覆盖。比语句覆盖更严格。
if (temp > 100) {
// 真分支
} else {
// 假分支
}
// 需要测试 temp > 100 和 temp <= 100 两种情况
MC/DC覆盖(Modified Condition/Decision Coverage)
这是最严格的覆盖率标准。医疗设备(尤其是IEC 62304要求的)经常需要MC/DC覆盖。
核心要求:
- 每个条件(condition)的真假都要影响结果
- 每个条件独立变化时,结果要跟着变
// 条件:A && B
// 需要测试:
// A=true, B=true -> 结果=true
// A=false, B=true -> 结果=false (A独立影响)
// A=true, B=false -> 结果=false (B独立影响)
为什么MC/DC重要?
在医疗显微镜里,一个条件判断失误可能意味着误诊。比如「温度过高且持续超过5秒」才报警,如果只测了温度高但没测持续时间,那报警逻辑可能永远不触发。MC/DC就是逼你把所有组合都测一遍。
覆盖率工具推荐
| 工具 | 支持类型 | 适用平台 |
|---|---|---|
| gcov | 语句、分支 | Linux/GCC |
| BullseyeCoverage | 语句、分支、MC/DC | 嵌入式交叉编译 |
| Tessy | 全支持 | 汽车/医疗行业 |
避坑指南:
我曾经用gcov测一个中断处理函数,发现覆盖率总是达不到100%。后来发现是中断触发条件太苛刻,测试环境模拟不了。解决办法是:把中断处理函数拆成两部分——中断入口和业务逻辑。业务逻辑单独测试,覆盖率就上去了。嗯,有时候要灵活变通。
4.4 实战建议
最后,我总结几条实战经验:
- 先写测试,再写代码——TDD在医疗设备里完全适用。我试过,bug率至少降一半。
- 桩函数要可配置——用宏或者回调函数,方便切换真实和桩函数。
- 覆盖率不是目标,是手段——别为了凑覆盖率写无意义的测试。
- 自动化运行——每次提交代码,自动跑单元测试。我见过有人手动跑测试,结果漏了一次,产品就出问题了。
单元测试做扎实了,后面的集成测试和系统测试会轻松很多。你想想看,如果每个函数都是可靠的,那把它们组合起来的时候,问题不就少多了吗?