11、测试驱动开发(TDD)入门:红-绿-重构循环、在C项目中实践TDD

说实话,我第一次接触TDD是在一个嵌入式项目里。当时被老板逼着写测试,我心里还嘀咕:这不浪费时间吗?结果真香了。后来我带团队,TDD成了标配。今天我就把压箱底的经验掏出来,跟你聊聊。

什么是TDD?说白了就是“先写测试,再写代码”

传统开发流程是:设计→编码→测试。TDD把这个顺序彻底翻了个个儿。它的核心循环只有三步:

  1. 红(Red):先写一个会失败的测试
  2. 绿(Green):写最简代码让测试通过
  3. 重构(Refactor):优化代码,保持测试通过

你想想看,这像不像先画靶子再射箭?我刚开始也觉得别扭,但用久了才发现,这其实是把“验收标准”提前了。你写代码前就知道要达成什么目标,自然不容易跑偏。

核心原则:没有失败的测试,就不写一行生产代码。测试通过后,立即停止写新功能,进入重构阶段。

红-绿-重构循环详解

第一步:红(Red)——写一个会失败的测试

假设我们要写一个计算环形缓冲区剩余空间的函数。按TDD流程,我先写测试:

// test_circular_buffer.c
#include <unity.h>
#include "circular_buffer.h"

void setUp(void) {}
void tearDown(void) {}

void test_remaining_space_returns_correct_value(void) {
    circular_buffer_t buf;
    uint8_t data[16];
    
    cb_init(&buf, data, 16);
    
    // 刚初始化,剩余空间应该是16
    TEST_ASSERT_EQUAL(16, cb_remaining(&buf));
    
    // 写入5个字节,剩余空间变成11
    uint8_t test_data[] = {1,2,3,4,5};
    cb_write(&buf, test_data, 5);
    TEST_ASSERT_EQUAL(11, cb_remaining(&buf));
}

int main(void) {
    UNITY_BEGIN();
    RUN_TEST(test_remaining_space_returns_correct_value);
    return UNITY_END();
}

这时候编译肯定报错——cb_remaining函数还没写呢。嗯,这就是“红”阶段。测试失败是预期的,别慌。

第二步:绿(Green)——写最简代码让测试通过

我个人的习惯是,这时候只写能通过测试的最简代码,绝不提前优化:

// circular_buffer.c
#include "circular_buffer.h"

uint32_t cb_remaining(circular_buffer_t *buf) {
    return buf->size - buf->count;
}

就这么一行?对,就一行。测试通过了,进入“绿”阶段。你可能会觉得这也太简单了,但TDD的精髓就是“小步快跑”。

我的经验:新手最容易犯的错就是在“绿”阶段写太多代码。记住,只要测试通过,立刻停手。多余的功能等下一个测试来驱动。

第三步:重构(Refactor)——优化但不改变行为

测试通过了,但代码可能不够优雅。比如上面的cb_remaining,如果缓冲区有边界情况需要处理呢?这时候可以重构:

// 重构后的版本
uint32_t cb_remaining(circular_buffer_t *buf) {
    if (buf == NULL) {
        return 0;  // 防御性编程
    }
    return buf->size - buf->count;
}

重构完要重新跑测试。只要测试还是绿的,就说明重构没破坏功能。我在项目中遇到过好几次,重构时不小心改坏了逻辑,全靠测试帮我兜底。

在C项目中实践TDD的完整流程

光说不练假把式。咱们用一个真实场景走一遍:实现一个简单的LED控制模块。

1. 搭建测试框架

我推荐用Unity或Ceedling。以Unity为例:

// 项目结构
project/
├── src/
│   └── led_controller.c
├── test/
│   └── test_led_controller.c
├── unity/
│   └── unity.c, unity.h
└── Makefile

2. 第一个测试:初始化LED

// test_led_controller.c
void test_led_init_sets_pin_low(void) {
    led_init();
    // 假设我们有一个模拟的GPIO寄存器
    TEST_ASSERT_EQUAL(0, GPIO_PORT_LED);
}

跑测试,红。然后写最简实现:

// led_controller.c
void led_init(void) {
    GPIO_PORT_LED = 0;
}

测试通过,绿。这时候可以重构——比如把GPIO地址做成宏定义。

3. 第二个测试:点亮LED

void test_led_on_sets_pin_high(void) {
    led_init();
    led_on();
    TEST_ASSERT_EQUAL(1, GPIO_PORT_LED);
}

红。写实现:

void led_on(void) {
    GPIO_PORT_LED = 1;
}

绿。重构?暂时不需要。

4. 边界情况测试

我曾经踩过一个坑:连续调用led_on两次,会不会有副作用?于是加测试:

void test_led_on_twice_remains_high(void) {
    led_init();
    led_on();
    led_on();
    TEST_ASSERT_EQUAL(1, GPIO_PORT_LED);
}

这个测试一开始就通过了,说明当前实现没问题。但如果以后改了实现,这个测试能帮我及时发现回归。

避坑指南:我曾经在项目中跳过边界测试,结果上线后LED在特定条件下闪烁异常。后来补上测试才发现,是GPIO写操作被优化掉了。从那以后,我每个函数至少写三个测试:正常路径、边界值、异常输入。

TDD在嵌入式C中的特殊考量

挑战 解决方案
硬件依赖 使用模拟层(Mock)替代真实硬件寄存器
中断处理 将中断逻辑与业务逻辑分离,单独测试业务部分
内存受限 测试在PC上运行,使用标准C库模拟
实时性要求 用测试验证算法复杂度,避免性能回归

你想想看,嵌入式开发最怕什么?怕硬件还没出来,软件没法测。TDD正好解决这个问题——你在PC上就能把逻辑测透,等硬件回来直接集成。

TDD的核心收益

  • 代码质量提升:每个函数都有测试覆盖,重构有底气
  • 设计更清晰:先写测试迫使你思考接口设计
  • 减少调试时间:bug在写代码阶段就被发现,而不是等到集成测试
  • 文档即代码:测试用例就是最好的使用文档

我个人习惯是,新项目前两周可能慢一点,但后面越写越快。因为测试积累起来后,改代码不再心惊胆战。

知识体系图:TDD核心逻辑

TDD红-绿-重构循环 🔴 红 写一个会失败的测试 🟢 绿 写最简代码通过测试 🔵 重构 优化代码保持测试通过 循环迭代,每次增加一个新功能 关键原则 没有失败的测试,不写生产代码 | 测试通过立即停止 | 重构时测试必须保持绿色

这张图我画了好几次才满意。你看,红→绿→重构,然后回到红,形成一个闭环。每次循环只加一个小功能,步子迈得小,但走得稳。

常见误区与避坑

  • 测试写得太复杂:一个测试只测一件事。如果测试失败了不知道哪里错,说明测试设计有问题。
  • 跳过重构阶段:我见过很多人测试通过了就完事,代码越堆越乱。重构不是可选项,是必选项。
  • 测试依赖执行顺序:每个测试必须独立运行。我在项目中吃过这个亏,两个测试共享全局变量,结果一个失败导致连锁反应。
  • 忽视编译警告:TDD不只是测逻辑,也要测代码质量。打开-Wall -Werror,让编译器帮你把关。

我的小技巧:刚开始实践TDD时,可以先用“事后补测试”过渡。也就是先写代码,再补测试,然后重构。等习惯了,再尝试“先写测试”。别一上来就追求完美,慢慢来。

好了,关于TDD入门就聊这么多。记住,红-绿-重构不是教条,而是一种节奏。找到适合自己的节奏,TDD会成为你最趁手的工具。


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