33、单元测试:CUnit/Unity框架使用,测试用例编写,覆盖率
说实话,很多C语言开发者对单元测试的态度是「知道很重要,但就是懒得写」。我早年也这样,觉得嵌入式代码跑在硬件上,测不测无所谓。直到有一次,一个定时器中断里的边界条件没测到,产品在现场跑了三天就死机了……从那以后,我再也不敢跳过单元测试。
单元测试说白了,就是把你的函数从整个系统里「拆」出来,单独验证它对不对。你想想看,一个函数有几十条分支,靠肉眼review能看出多少?不如让测试代码替你跑一遍。
为什么C语言需要单元测试框架?
你可能觉得,写个main函数调一下不就行了?嗯,对于一两个函数确实可以。但项目大了以后,你会发现:
- 每次都要手动改main函数,麻烦
- 测试结果没有统一格式,没法自动化
- 没人知道哪些分支测过了,哪些没测
框架就是来解决这些问题的。它帮你做三件事:组织测试用例、断言结果、统计覆盖率。我个人习惯用Unity,轻量、无依赖,非常适合嵌入式环境。
Unity框架快速上手
Unity是啥?一个纯C的测试框架,就一个.c和一个.h文件。你把它拷到项目里,写几个测试函数,然后跑起来就行。
先看一个最简单的例子。假设你要测试一个计算校验和的函数:
// calc.h
uint8_t calc_checksum(const uint8_t* data, uint32_t len);
// calc.c
uint8_t calc_checksum(const uint8_t* data, uint32_t len) {
uint8_t sum = 0;
for (uint32_t i = 0; i < len; i++) {
sum += data[i];
}
return sum;
}
现在写测试:
// test_calc.c
#include "unity.h"
#include "calc.h"
void setUp(void) {} // 每个测试前执行
void tearDown(void) {} // 每个测试后执行
void test_checksum_empty_data(void) {
uint8_t data[] = {};
TEST_ASSERT_EQUAL_UINT8(0, calc_checksum(data, 0));
}
void test_checksum_normal(void) {
uint8_t data[] = {0x01, 0x02, 0x03};
TEST_ASSERT_EQUAL_UINT8(0x06, calc_checksum(data, 3));
}
void test_checksum_overflow(void) {
uint8_t data[] = {0xFF, 0x01};
// 0xFF + 0x01 = 0x100,截断为0x00
TEST_ASSERT_EQUAL_UINT8(0x00, calc_checksum(data, 2));
}
int main(void) {
UNITY_BEGIN();
RUN_TEST(test_checksum_empty_data);
RUN_TEST(test_checksum_normal);
RUN_TEST(test_checksum_overflow);
return UNITY_END();
}
编译运行,你会看到类似这样的输出:
test_calc.c:12:test_checksum_empty_data:PASS
test_calc.c:17:test_checksum_normal:PASS
test_calc.c:22:test_checksum_overflow:PASS
-----------------------
3 Tests 0 Failures 0 Ignored
OK
测试用例编写的核心思路
写测试用例不是瞎写。我总结了一个简单的原则:输入边界 + 正常路径 + 异常路径。
举个例子,你要测一个限幅函数:
int16_t clamp(int16_t value, int16_t min, int16_t max);
测试用例应该覆盖:
- 正常值:value在[min, max]内,返回value本身
- 低于下限:value < min,返回min
- 高于上限:value > max,返回max
- 边界相等:value == min 或 value == max
- min > max:参数错误,函数怎么处理?
你看,一个简单的限幅函数,至少5个测试用例。我在项目中遇到过有人只写了前三个,结果min==max时出了bug,找了半天。
覆盖率:你到底测了多少?
覆盖率是个好东西,但别迷信。我见过有人追求100%行覆盖率,结果所有分支都跑了,但组合条件没测到。覆盖率工具一般支持以下几种:
| 类型 | 含义 | 我的建议 |
|---|---|---|
| 行覆盖率 | 每行代码是否被执行 | 至少90%以上 |
| 分支覆盖率 | 每个if/else是否都走过 | 100%最好 |
| 条件覆盖率 | 每个布尔子表达式是否都取过true/false | 关键模块必做 |
| MC/DC | 每个条件独立影响结果 | 安全关键系统要求 |
在嵌入式C项目中,我一般用gcov配合lcov生成覆盖率报告。命令很简单:
gcc -fprofile-arcs -ftest-coverage test_calc.c calc.c -o test_calc
./test_calc
gcov calc.c
lcov -c -d . -o coverage.info
genhtml coverage.info -o report
然后打开report/index.html,就能看到哪些行是红色(没覆盖到),哪些是绿色(覆盖到了)。
嵌入式环境下的特殊问题
嵌入式代码经常依赖硬件寄存器、中断、外设。这些在PC上没法直接跑。怎么办?我的做法是:
- 硬件抽象层(HAL):把硬件操作封装成函数,测试时用模拟实现替换
- 条件编译:用#ifdef UNIT_TEST隔离硬件相关代码
- 模拟器:有些MCU厂商提供指令集模拟器,可以在上面跑测试
举个例子,你有一个读取ADC的函数:
// adc.h
uint16_t adc_read(uint8_t channel);
// adc.c
uint16_t adc_read(uint8_t channel) {
// 操作硬件寄存器
ADC->CHSEL = channel;
ADC->START = 1;
while (!(ADC->FLAG & ADC_FLAG_EOC));
return ADC->DATA;
}
测试时,你可以这样模拟:
// adc_mock.c
static uint16_t mock_values[8] = {0};
void adc_mock_set(uint8_t channel, uint16_t value) {
mock_values[channel] = value;
}
uint16_t adc_read(uint8_t channel) {
return mock_values[channel];
}
然后在测试代码里,链接adc_mock.c而不是adc.c。这样就能在PC上测试所有逻辑了。
知识体系总览
下面这张图概括了本章的核心内容,从框架选择到覆盖率分析,一条线串起来:
从框架选择开始,到测试用例编写,最后用覆盖率工具验证。每一步都有对应的工具和方法。我个人建议新手从Unity开始,它简单到你看一眼就会用。
写在最后
单元测试这件事,刚开始会觉得「多此一举」。但当你维护一个超过10万行代码的项目时,你就会感谢当初写了测试的自己。我见过太多bug是因为改了一处,另一处崩了,而测试能第一时间告诉你。
别追求完美,先跑起来。哪怕只给核心模块写了测试,也比没有强。记住:没有测试的代码,就是遗留代码。