29、测试驱动开发:单元测试框架、Mock对象、覆盖率分析
说实话,很多C语言工程师对测试驱动开发(TDD)有误解。他们觉得嵌入式系统资源有限,搞什么测试?浪费时间。我以前也这么想,直到有一次,一个简单的定时器溢出bug,让我在产线前熬了三个通宵。从那以后,我再也不敢轻视测试了。
TDD不是花架子。它是一套能让你睡得踏实的方法论。说白了,就是先写测试,再写代码。红-绿-重构,三步循环。今天我就带你把这套东西吃透。
单元测试框架:你的第一道防线
在C语言世界里,单元测试框架有很多。我个人最常用的是CUnit和Unity。Unity特别适合嵌入式,因为它轻量,不依赖标准库。
来看一个最简单的例子。假设你要测试一个计算校验和的函数:
// 待测试的函数
uint8_t calc_checksum(const uint8_t* data, size_t len) {
uint8_t sum = 0;
for (size_t i = 0; i < len; i++) {
sum += data[i];
}
return sum;
}
// 使用Unity框架的测试用例
#include "unity.h"
void test_calc_checksum_should_return_correct_value(void) {
uint8_t data[] = {0x01, 0x02, 0x03};
uint8_t result = calc_checksum(data, 3);
TEST_ASSERT_EQUAL_UINT8(0x06, result);
}
void test_calc_checksum_should_handle_empty_data(void) {
uint8_t result = calc_checksum(NULL, 0);
TEST_ASSERT_EQUAL_UINT8(0x00, result);
}
int main(void) {
UNITY_BEGIN();
RUN_TEST(test_calc_checksum_should_return_correct_value);
RUN_TEST(test_calc_checksum_should_handle_empty_data);
return UNITY_END();
}
你看,测试用例的名字我习惯写得长一点,把「被测方法」和「期望行为」都写进去。这样以后看测试报告,一眼就知道哪里出了问题。
Mock对象:把依赖关进笼子里
嵌入式开发最头疼的是什么?硬件依赖。你写了个驱动,要测试它,但硬件还没回来。怎么办?Mock对象就是干这个的。
Mock说白了就是「假货」。它模拟真实模块的行为,让你能隔离测试。我推荐CMock这个工具,它能自动生成Mock代码。
举个例子。你的业务逻辑里调用了hal_i2c_write(),但I2C硬件还没焊好。你可以这样Mock:
// 原始头文件 hal_i2c.h
int hal_i2c_write(uint8_t dev_addr, uint8_t reg_addr, uint8_t* data, size_t len);
// 测试代码中,CMock自动生成mock
#include "mock_hal_i2c.h"
void test_sensor_read_should_call_i2c_write(void) {
// 设定期望:hal_i2c_write会被调用,参数是0x50, 0x01, 任意指针, 2
hal_i2c_write_ExpectAndReturn(0x50, 0x01, NULL, 2, 0);
// 调用被测函数
int ret = sensor_read(0x50, 0x01);
// 验证结果
TEST_ASSERT_EQUAL_INT(0, ret);
}
这里的关键是:Mock让你能验证「交互」。你不光要测返回值对不对,还要测你的代码有没有正确地调用底层接口。我在项目中遇到过好几次,逻辑看起来没问题,但参数传错了。Mock一跑,原形毕露。
覆盖率分析:别被数字骗了
覆盖率是个好东西,但也是个陷阱。很多团队把「覆盖率100%」当KPI,结果大家写一堆没用的测试来凑数。你想想看,这有什么意义?
常见的覆盖率指标有四种:
| 指标 | 含义 | 我的建议 |
|---|---|---|
| 语句覆盖率 | 每行代码是否被执行过 | 基础指标,但不够 |
| 分支覆盖率 | 每个if/else是否都走过 | 至少达到90% |
| 条件覆盖率 | 每个布尔子表达式是否都测过 | 关键模块必须做 |
| MC/DC覆盖率 | 每个条件独立影响结果 | 安全关键系统(如汽车、医疗)强制要求 |
在C语言里,我常用gcov和lcov来做覆盖率分析。用法很简单:
# 编译时加覆盖率选项
gcc -fprofile-arcs -ftest-coverage -o test_suite test_suite.c
# 运行测试
./test_suite
# 生成覆盖率报告
gcov test_suite.c
lcov --capture --directory . --output-file coverage.info
genhtml coverage.info --output-directory coverage_report
打开生成的HTML报告,你会看到每行代码被命中了几次。红色表示没跑到,绿色表示跑到了。嗯,这时候你就要问自己:为什么那行代码没跑到?是测试没写全,还是那行代码是死代码?
知识体系总览
下面这张图,是我对TDD在C语言中落地的整体理解。你可以把它当作一张地图,随时回来看看。
避坑指南:我踩过的那些坑
讲到这里,我想分享几个我亲身经历过的教训。
第一个坑:测试代码不维护。 我曾经接手一个项目,测试代码比业务代码还多,但全部是五年前写的,编译都过不了。为什么?因为业务代码改了,测试没跟着改。记住:测试代码也是代码,需要同样级别的维护。
第二个坑:Mock对象太「完美」。 我见过有人把Mock写得和真实硬件一模一样,连时序都模拟了。结果测试全过,上板就挂。为什么?因为Mock里没有噪声,没有中断延迟,没有总线竞争。我的建议是:Mock只模拟接口契约,不要模拟行为细节。真实行为交给集成测试去验证。
第三个坑:追求100%覆盖率。 有一回,团队老大要求覆盖率必须100%。结果大家开始写「凑数测试」——比如测一个return 0;的函数,也要写个用例。这有什么意义?我个人的标准是:核心逻辑90%以上,关键路径100%,剩下的边缘情况,根据风险评估决定要不要测。
好了,关于TDD在C语言中的落地,我就讲这么多。记住:测试不是负担,是你的安全网。下次你改代码的时候,有测试兜底,你才敢大胆重构。这才是TDD真正的价值。
公众号:蓝海资料掘金营,微信deep3321