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 实战建议

最后,我总结几条实战经验:

  1. 先写测试,再写代码——TDD在医疗设备里完全适用。我试过,bug率至少降一半。
  2. 桩函数要可配置——用宏或者回调函数,方便切换真实和桩函数。
  3. 覆盖率不是目标,是手段——别为了凑覆盖率写无意义的测试。
  4. 自动化运行——每次提交代码,自动跑单元测试。我见过有人手动跑测试,结果漏了一次,产品就出问题了。

单元测试做扎实了,后面的集成测试和系统测试会轻松很多。你想想看,如果每个函数都是可靠的,那把它们组合起来的时候,问题不就少多了吗?