好的,没问题。作为一名在嵌入式领域摸爬滚打多年的老工程师,我来给你讲讲测试驱动开发(TDD)这门手艺。这可不是什么花架子,是真正能帮你省下大把调试时间的利器。

15、测试驱动开发:TDD流程、红绿重构循环、Mock对象、覆盖率分析

说实话,我刚入行那会儿,写代码的习惯是「写完再说」。结果呢?经常是功能调通了,一改需求就崩,或者改了个小bug,引出三个新bug。那段时间,我几乎天天在跟调试器较劲。

后来我接触到了TDD,也就是测试驱动开发。一开始我也觉得麻烦——代码都没写,先写测试?这不是本末倒置吗?但真正用起来才发现,这玩意儿是真香。它逼着你在动手前想清楚「我要什么」,而不是「我该怎么写」。

核心思想: 先写测试,再写实现代码。测试通过后,再重构优化。整个过程就像给代码上了一道保险,让你改起来心里有底。

15.1 TDD的经典三循环:红-绿-重构

TDD的流程,说白了就是一个三步走的循环。我习惯叫它「红绿重构」,听起来像交通信号灯,对吧?

  1. 红(Red): 先写一个会失败的测试。这个测试描述了你要添加的一个小功能点。因为还没写实现代码,所以测试必然失败。IDE或测试框架会给你一个红色的失败提示。
  2. 绿(Green): 写最少的代码,让这个测试通过。别想太多,别搞什么优雅设计,目标只有一个:让红灯变绿灯。哪怕代码写得再丑,只要通过就行。
  3. 重构(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.hgcd.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项目中,我常用 gcovlcov 来生成覆盖率报告。用法很简单:

# 编译时加 -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 核心流程与支撑体系 红-绿-重构 循环 🔴 红 (Red) 写一个会失败的测试 🟢 绿 (Green) 写最少代码让测试通过 🔄 重构 (Refactor) 优化代码,保持测试通过 循环迭代,逐步增加功能 关键支撑技术 🎭 Mock对象 📊 覆盖率分析 🧪 测试框架 (Unity/CUnit)

你想想看,有了这套流程和工具,写代码是不是更像是在雕琢一件艺术品?每一步都有反馈,每一步都心里有数。我个人觉得,TDD最大的价值不是「测试」,而是「设计」。它逼着你把大问题拆成小问题,把模糊的需求变成可验证的用例。

好了,关于TDD的核心内容就聊到这儿。记住,红绿重构是骨架,Mock和覆盖率是血肉。多练几次,你就能体会到它的好处了。

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