模块化编程中的测试策略:单元测试框架选择、用例编写、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。说白了,选框架就是选“适配度”

我的建议:如果你在PC上做原型验证,用CUnit;如果最终要跑在MCU上,直接用Unity。别两头折腾。

测试用例怎么写?从“测功能”到“测边界”

很多新手写测试用例,只测“正常情况”。比如一个计算校验和的函数,只传有效数据。这其实不够。我见过一个案例:某工程师写了一个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(传感器故障),看温度模块是否进入错误处理流程。

注意:Mock不是万能的。如果Mock的返回值与真实硬件行为差距太大,测试就失去了意义。我曾经犯过一个错:Mock了ADC读取函数,总是返回0x800,结果真实ADC在噪声下会抖动,导致实际滤波算法失效。所以,Mock要“像真的”,但“可控”

测试覆盖率:别被数字骗了

覆盖率工具(如gcov、LCOV)能告诉你哪些代码被执行了。但我要泼一盆冷水:100%行覆盖率不代表代码没问题

我见过一个项目,覆盖率报告显示95%,但现场还是出了bug。为什么?因为覆盖率只统计“执行了哪一行”,不统计“执行了哪些路径”。比如一个if-else语句,你只测了if分支,else分支没测,但覆盖率工具可能因为行号重叠而显示“已覆盖”。

我个人建议关注三个指标:

  • 函数覆盖率:每个函数至少被调用一次
  • 分支覆盖率:每个if/else、switch/case都走到
  • MC/DC覆盖率:每个条件独立影响结果(安全关键项目必做)

在嵌入式项目中,我通常要求函数覆盖率≥90%,分支覆盖率≥80%。低于这个数,说明测试用例没写全。

核心观点:覆盖率是“体检报告”,不是“健康证明”。别为了凑数字而写无意义的测试用例。

一张图看懂测试策略

下面这张SVG图,是我自己总结的测试策略流程。从框架选择到覆盖率分析,一条线串起来:

嵌入式C单元测试策略流程 选择测试框架 CUnit / Unity 编写测试用例 正常/边界/异常 Mock外部依赖 I2C/SPI/硬件 执行测试 自动化/持续集成 覆盖率分析 gcov / LCOV 达标? 不达标:补充用例 达标:发布 虚线表示不达标时的回退路径

这张图的核心逻辑是:先选框架,再写用例,Mock掉依赖,然后执行并分析覆盖率。如果覆盖率不达标,就回去补用例,直到达标为止。

避坑指南:我踩过的三个坑

最后分享几个实战教训,都是真金白银换来的:

  • 坑一:Mock了所有东西,结果测试通过,集成时崩溃。 原因:Mock的返回值太理想,没模拟硬件延迟。后来我加了一个“延迟模拟”参数,让Mock可以随机返回超时。
  • 坑二:覆盖率工具在MCU上跑不起来。 有些MCU没有文件系统,gcov的输出没法保存。我的解决方案是:在PC上用模拟器(如QEMU)跑测试,覆盖率在PC端生成。
  • 坑三:测试用例太多,维护成本爆炸。 我曾经一个模块写了200个用例,结果每次需求变更都要改50个。后来我学会了“按接口分组”,只测公开接口,内部函数通过静态分析保证质量。
一个小技巧:用Unity时,可以自定义TEST_ASSERT宏,把测试结果通过串口打印出来。这样即使没有PC,也能在MCU上看到测试报告。

好了,关于单元测试的策略就聊到这儿。记住:测试不是负担,是保险。你花在测试上的每一分钟,都会在调试时加倍还给你。


公众号:蓝海资料掘金营,微信deep3321