15、测试驱动开发:TDD流程、红绿重构循环、Mock对象、覆盖率分析
说实话,我刚入行那会儿,写代码的习惯是「写完再说」。结果呢?经常是功能调通了,一改需求就崩,或者改了个小bug,引出三个新bug。那段时间,我几乎天天在跟调试器较劲。
后来我接触到了TDD,也就是测试驱动开发。一开始我也觉得麻烦——代码都没写,先写测试?这不是本末倒置吗?但真正用起来才发现,这玩意儿是真香。它逼着你在动手前想清楚「我要什么」,而不是「我该怎么写」。
核心思想: 先写测试,再写实现代码。测试通过后,再重构优化。整个过程就像给代码上了一道保险,让你改起来心里有底。
15.1 TDD的经典三循环:红-绿-重构
TDD的流程,说白了就是一个三步走的循环。我习惯叫它「红绿重构」,听起来像交通信号灯,对吧?
- 红(Red): 先写一个会失败的测试。这个测试描述了你要添加的一个小功能点。因为还没写实现代码,所以测试必然失败。IDE或测试框架会给你一个红色的失败提示。
- 绿(Green): 写最少的代码,让这个测试通过。别想太多,别搞什么优雅设计,目标只有一个:让红灯变绿灯。哪怕代码写得再丑,只要通过就行。
- 重构(Refactor): 测试通过了,现在可以放心大胆地优化代码了。去掉重复、改善命名、调整结构。因为有测试给你兜底,你不用担心改坏了。
嗯,这里要注意:重构的前提是「绿灯」状态。如果测试是红的,千万别动重构的心思,先把功能搞对再说。
我的习惯: 每次循环只加一个非常小的测试。比如,先测一个函数返回0的情况,再测返回正数的情况。步子迈小了,才不会扯着蛋。
15.2 一个简单的C语言TDD例子
光说不练假把式。我们拿一个简单的例子来走一遍流程。假设我们要写一个函数,计算两个整数的最大公约数(GCD)。
第一步:写一个失败的测试(红)
我通常会先用一个测试框架,比如CUnit或Unity。这里我用Unity来演示,因为它轻量,很适合嵌入式。
// test_gcd.c
#include "unity.h"
#include "gcd.h"
void setUp(void) {}
void tearDown(void) {}
void test_gcd_should_return_1_for_1_and_1(void) {
TEST_ASSERT_EQUAL(1, gcd(1, 1));
}
int main(void) {
UNITY_BEGIN();
RUN_TEST(test_gcd_should_return_1_for_1_and_1);
return UNITY_END();
}
这时候编译运行,肯定会报错,因为 gcd.h 和 gcd.c 都还没写。这就是红灯。
第二步:写最少的代码让测试通过(绿)
好,现在目标很明确:让这个测试通过。我只需要写一个最简单的实现。
// gcd.h
#ifndef GCD_H
#define GCD_H
int gcd(int a, int b);
#endif
// gcd.c
#include "gcd.h"
int gcd(int a, int b) {
return 1; // 先写死,让测试通过
}
你看,我直接返回了1。这代码很蠢,但测试通过了。这就是「绿」的状态。
第三步:重构(Refactor)
测试通过了,但实现是假的。现在我需要写真正的算法。因为我有测试,我可以放心地改。
// gcd.c
#include "gcd.h"
int gcd(int a, int b) {
while (b != 0) {
int temp = b;
b = a % b;
a = temp;
}
return a;
}
改完后,再跑一遍测试。如果还是绿的,说明重构成功。如果红了,说明我改错了,得赶紧回退。
我曾经踩过的坑: 有一次我重构时,不小心把变量名写错了,导致死循环。幸好测试用例跑了几秒后超时了,我才意识到问题。所以,重构时一定要频繁运行测试,别攒了一大堆改动再跑。
15.3 Mock对象:解决依赖问题
在嵌入式开发中,我们的代码经常要跟硬件打交道,比如读写寄存器、操作外设。这些依赖在单元测试时是没法直接用的。怎么办?用Mock对象。
Mock说白了,就是「假货」。它模拟了真实依赖的行为,让你能控制测试环境。比如,你要测试一个温度传感器驱动,但硬件还没焊好。你可以Mock一个I2C读写函数,让它返回你预设的数据。
举个例子,假设我们有这样一个函数:
// sensor.c
#include "i2c.h"
int read_temperature(void) {
uint8_t data[2];
i2c_read(0x48, data, 2); // 从I2C地址0x48读2个字节
return (data[0] << 8) | data[1];
}
要测试它,我们不能真的去读硬件。所以,我们Mock掉 i2c_read 函数。
// test_sensor.c
#include "unity.h"
#include "sensor.h"
// Mock的i2c_read函数
static uint8_t mock_data[2];
void i2c_read(uint8_t addr, uint8_t *buf, uint8_t len) {
// 忽略addr和len,直接返回预设数据
buf[0] = mock_data[0];
buf[1] = mock_data[1];
}
void setUp(void) {
mock_data[0] = 0x01;
mock_data[1] = 0x2C; // 预设温度值 0x012C = 300
}
void test_read_temperature_should_return_300(void) {
TEST_ASSERT_EQUAL(300, read_temperature());
}
int main(void) {
UNITY_BEGIN();
RUN_TEST(test_read_temperature_should_return_300);
return UNITY_END();
}
你看,通过Mock,我们把不可控的硬件依赖变成了可控的数据。这在嵌入式测试里是家常便饭。
我的建议: 在C语言里,Mock通常通过链接时替换来实现。你可以在测试文件里定义一个同名的函数,链接器会优先使用你的Mock版本。但要注意,别把Mock和真实实现搞混了。
15.4 覆盖率分析:看看你的测试到底测了啥
测试写完了,你怎么知道测得全不全?覆盖率分析就是干这个的。它告诉你,你的测试代码执行了被测代码的哪些部分。
常用的覆盖率指标有几种:
| 指标 | 含义 | 我的看法 |
|---|---|---|
| 语句覆盖率 | 每行代码是否被执行过 | 最基础,但不够。100%语句覆盖不代表没bug。 |
| 分支覆盖率 | 每个if/else、switch case是否都走过 | 比语句覆盖更有用。我一般要求分支覆盖到90%以上。 |
| 条件覆盖率 | 每个布尔子表达式是否都取过true和false | 更严格,适合安全关键系统。 |
| MC/DC覆盖率 | 每个条件独立影响结果 | 航空、汽车领域常用,成本很高。 |
在嵌入式C项目中,我常用 gcov 和 lcov 来生成覆盖率报告。用法很简单:
# 编译时加 -fprofile-arcs -ftest-coverage
gcc -fprofile-arcs -ftest-coverage test_gcd.c gcd.c -o test_gcd
# 运行测试
./test_gcd
# 生成覆盖率报告
gcov gcd.c
lcov --capture --directory . --output-file coverage.info
genhtml coverage.info --output-directory coverage_report
打开生成的HTML报告,你就能看到哪些代码被测试覆盖了(绿色),哪些没有(红色)。
注意: 覆盖率100%不代表代码没有bug。它只说明你的测试用例执行了所有代码路径。但逻辑错误、边界条件遗漏,覆盖率是看不出来的。我曾经有个项目,覆盖率95%,但漏了一个除零的边界情况,结果现场跑飞了。所以,覆盖率是工具,不是目标。
15.5 知识体系图
下面这张图,是我对TDD整个流程和关键要素的总结。你可以把它当作一个快速参考。
你想想看,有了这套流程和工具,写代码是不是更像是在雕琢一件艺术品?每一步都有反馈,每一步都心里有数。我个人觉得,TDD最大的价值不是「测试」,而是「设计」。它逼着你把大问题拆成小问题,把模糊的需求变成可验证的用例。
好了,关于TDD的核心内容就聊到这儿。记住,红绿重构是骨架,Mock和覆盖率是血肉。多练几次,你就能体会到它的好处了。