29、测试驱动开发:单元测试框架、Mock对象、覆盖率分析

说实话,很多C语言工程师对测试驱动开发(TDD)有误解。他们觉得嵌入式系统资源有限,搞什么测试?浪费时间。我以前也这么想,直到有一次,一个简单的定时器溢出bug,让我在产线前熬了三个通宵。从那以后,我再也不敢轻视测试了。

TDD不是花架子。它是一套能让你睡得踏实的方法论。说白了,就是先写测试,再写代码。红-绿-重构,三步循环。今天我就带你把这套东西吃透。

单元测试框架:你的第一道防线

在C语言世界里,单元测试框架有很多。我个人最常用的是CUnitUnity。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一跑,原形毕露。

注意:Mock不是万能的。过度Mock会让测试变成「自嗨」。你Mock了所有依赖,测的其实是你自己的假设,而不是真实行为。我曾经见过一个项目,测试覆盖率90%,但一上硬件就崩。为什么?因为Mock的行为和真实硬件不一样。所以,关键路径上的模块,尽量用真实实现或硬件在环测试。

覆盖率分析:别被数字骗了

覆盖率是个好东西,但也是个陷阱。很多团队把「覆盖率100%」当KPI,结果大家写一堆没用的测试来凑数。你想想看,这有什么意义?

常见的覆盖率指标有四种:

指标 含义 我的建议
语句覆盖率 每行代码是否被执行过 基础指标,但不够
分支覆盖率 每个if/else是否都走过 至少达到90%
条件覆盖率 每个布尔子表达式是否都测过 关键模块必须做
MC/DC覆盖率 每个条件独立影响结果 安全关键系统(如汽车、医疗)强制要求

在C语言里,我常用gcovlcov来做覆盖率分析。用法很简单:

# 编译时加覆盖率选项
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报告,你会看到每行代码被命中了几次。红色表示没跑到,绿色表示跑到了。嗯,这时候你就要问自己:为什么那行代码没跑到?是测试没写全,还是那行代码是死代码?

核心观点:覆盖率是手段,不是目的。我见过有人为了凑分支覆盖率,写了一个测试用例,里面全是if-else的排列组合,但核心逻辑根本没测。别干这种事。先保证关键路径全覆盖,再考虑边缘情况。

知识体系总览

下面这张图,是我对TDD在C语言中落地的整体理解。你可以把它当作一张地图,随时回来看看。

C语言测试驱动开发(TDD)知识体系 TDD 红-绿-重构 单元测试框架 Mock对象 覆盖率分析 Unity / CUnit CMock / Fake gcov / lcov MC/DC分析 最佳实践 先写测试 → 只测一个点 → 隔离依赖 → 关注关键路径 → 覆盖率是手段不是目的

避坑指南:我踩过的那些坑

讲到这里,我想分享几个我亲身经历过的教训。

第一个坑:测试代码不维护。 我曾经接手一个项目,测试代码比业务代码还多,但全部是五年前写的,编译都过不了。为什么?因为业务代码改了,测试没跟着改。记住:测试代码也是代码,需要同样级别的维护。

第二个坑:Mock对象太「完美」。 我见过有人把Mock写得和真实硬件一模一样,连时序都模拟了。结果测试全过,上板就挂。为什么?因为Mock里没有噪声,没有中断延迟,没有总线竞争。我的建议是:Mock只模拟接口契约,不要模拟行为细节。真实行为交给集成测试去验证。

第三个坑:追求100%覆盖率。 有一回,团队老大要求覆盖率必须100%。结果大家开始写「凑数测试」——比如测一个return 0;的函数,也要写个用例。这有什么意义?我个人的标准是:核心逻辑90%以上,关键路径100%,剩下的边缘情况,根据风险评估决定要不要测。

一个实用技巧:在嵌入式项目中,我习惯把测试分为三个层级:单元测试(纯逻辑)、模块测试(带Mock)、集成测试(真实硬件)。每个层级的侧重点不同,用的工具也不同。别指望一个测试框架解决所有问题。

好了,关于TDD在C语言中的落地,我就讲这么多。记住:测试不是负担,是你的安全网。下次你改代码的时候,有测试兜底,你才敢大胆重构。这才是TDD真正的价值。


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