第16章 测试驱动开发:TDD流程、红绿重构循环、实战案例

测试驱动开发,圈里人常说的TDD,其实不是什么高深莫测的玄学。说白了就是「先写测试,再写代码」。我第一次接触这个概念是在一个嵌入式项目里,当时被bug追着跑,改一个地方崩三个地方。后来一位老同事扔给我一句话:「你试试先写测试,再写代码。」嗯,这一试,就回不去了。

16.1 TDD的核心流程

TDD的流程,我习惯用一个循环来概括:红 → 绿 → 重构。这三个字,就是TDD的全部秘密。

红绿重构循环

  • 红(Red):先写一个会失败的测试。这时候代码还没写,测试当然跑不过,显示红色。
  • 绿(Green):写最少的代码,让测试通过。别想太多,能跑就行,显示绿色。
  • 重构(Refactor):在测试保护下,优化代码。保证绿色不变,但代码更干净。

你想想看,这个循环像不像一个「安全网」?每次改动都有测试兜底,心里踏实。

TDD 红绿重构循环 写失败测试 绿 写最少代码通过 重构 优化代码 循环迭代 每次循环只增加一个功能点,保持测试全部通过

16.2 红绿重构,每一步都别跳

很多新手上来就想「一步到位」,测试写了一大堆,代码也写了一大堆,结果跑起来全是红的。我个人习惯是:一次只加一个测试,一次只改一行代码。步子迈小一点,反而更快。

16.2.1 红阶段:写一个会失败的测试

假设我们要写一个函数,计算环形缓冲区中有效数据的个数。先别急着写实现,先写测试:

// test_ring_buffer.c
#include <unity.h>
#include "ring_buffer.h"

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

void test_ring_buffer_count_empty(void) {
    ring_buffer_t buf;
    ring_buffer_init(&buf);
    TEST_ASSERT_EQUAL(0, ring_buffer_count(&buf));
}

void test_ring_buffer_count_after_one_push(void) {
    ring_buffer_t buf;
    ring_buffer_init(&buf);
    ring_buffer_push(&buf, 0x42);
    TEST_ASSERT_EQUAL(1, ring_buffer_count(&buf));
}

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

这时候编译,肯定报错——ring_buffer_count 还没定义呢。红色,很正常。我在项目中遇到过有人看到红色就慌,其实红色是好事,它告诉你「测试已经就位,就差代码了」。

16.2.2 绿阶段:写最少代码让测试通过

别想太多,写最笨的代码,只要测试能绿:

// ring_buffer.c
#include "ring_buffer.h"

void ring_buffer_init(ring_buffer_t *buf) {
    buf->head = 0;
    buf->tail = 0;
    buf->count = 0;
}

void ring_buffer_push(ring_buffer_t *buf, uint8_t data) {
    buf->buffer[buf->head] = data;
    buf->head = (buf->head + 1) % RING_BUFFER_SIZE;
    buf->count++;
}

uint32_t ring_buffer_count(ring_buffer_t *buf) {
    return buf->count;
}

编译,运行,测试全绿。嗯,舒服了。但别高兴太早,这只是第一步。

我的小技巧:绿阶段不要写任何「可能有用但测试没要求」的代码。比如边界检查、错误处理,统统先别加。测试没要求,你加了反而可能引入bug。等下一个红阶段再说。

16.2.3 重构阶段:在安全网下优化

现在测试是绿的,我们可以放心重构了。比如,ring_buffer_count 的实现其实可以不用 count 字段,直接用 headtail 算出来:

uint32_t ring_buffer_count(ring_buffer_t *buf) {
    if (buf->head >= buf->tail) {
        return buf->head - buf->tail;
    } else {
        return RING_BUFFER_SIZE - (buf->tail - buf->head);
    }
}

改完之后,重新跑测试。还是绿的。好,重构成功。如果红了,说明我改错了,立刻回退。这就是测试给我们的底气。

我曾经踩过的坑:重构阶段改完代码后,忘了跑测试。结果提交代码后,同事一拉,编译都过不了。从那以后,我给自己定了个规矩:重构完必须跑一遍全部测试,少一个都不行。

16.3 实战案例:一个环形缓冲区的TDD全过程

光说不练假把式。我们拿一个完整的环形缓冲区模块,走一遍TDD流程。这个案例我当年在做一个传感器数据采集项目时用过,效果很好。

16.3.1 第一步:定义接口头文件

// ring_buffer.h
#ifndef RING_BUFFER_H
#define RING_BUFFER_H

#include <stdint.h>

#define RING_BUFFER_SIZE 16

typedef struct {
    uint8_t buffer[RING_BUFFER_SIZE];
    uint32_t head;
    uint32_t tail;
    uint32_t count;
} ring_buffer_t;

void ring_buffer_init(ring_buffer_t *buf);
void ring_buffer_push(ring_buffer_t *buf, uint8_t data);
uint8_t ring_buffer_pop(ring_buffer_t *buf);
uint32_t ring_buffer_count(ring_buffer_t *buf);
int ring_buffer_is_full(ring_buffer_t *buf);
int ring_buffer_is_empty(ring_buffer_t *buf);

#endif

16.3.2 第二步:按TDD循环逐个实现

我不会一口气把所有函数都写完。我习惯按这个顺序来:

循环次数 红阶段(新测试) 绿阶段(最少代码) 重构阶段
1 测试 init 后 count 为0 实现 init 和 count
2 测试 push 后 count 为1 实现 push
3 测试 pop 后 count 减1 实现 pop 优化 count 计算方式
4 测试满缓冲区情况 实现 is_full 提取公共代码
5 测试空缓冲区情况 实现 is_empty 整理头文件注释

你看,5个循环,每个循环只加一点点东西。测试始终是绿的(除了刚加测试的那一瞬间)。

16.3.3 第三步:完整的测试代码

// test_ring_buffer_full.c
#include <unity.h>
#include "ring_buffer.h"

ring_buffer_t buf;

void setUp(void) {
    ring_buffer_init(&buf);
}

void tearDown(void) {}

void test_init_sets_count_zero(void) {
    TEST_ASSERT_EQUAL(0, ring_buffer_count(&buf));
}

void test_push_increases_count(void) {
    ring_buffer_push(&buf, 0xAA);
    TEST_ASSERT_EQUAL(1, ring_buffer_count(&buf));
}

void test_pop_decreases_count(void) {
    ring_buffer_push(&buf, 0xBB);
    ring_buffer_pop(&buf);
    TEST_ASSERT_EQUAL(0, ring_buffer_count(&buf));
}

void test_pop_returns_pushed_value(void) {
    ring_buffer_push(&buf, 0xCC);
    TEST_ASSERT_EQUAL(0xCC, ring_buffer_pop(&buf));
}

void test_is_full_when_buffer_full(void) {
    for (int i = 0; i < RING_BUFFER_SIZE; i++) {
        ring_buffer_push(&buf, i);
    }
    TEST_ASSERT_TRUE(ring_buffer_is_full(&buf));
}

void test_is_empty_after_init(void) {
    TEST_ASSERT_TRUE(ring_buffer_is_empty(&buf));
}

int main(void) {
    UNITY_BEGIN();
    RUN_TEST(test_init_sets_count_zero);
    RUN_TEST(test_push_increases_count);
    RUN_TEST(test_pop_decreases_count);
    RUN_TEST(test_pop_returns_pushed_value);
    RUN_TEST(test_is_full_when_buffer_full);
    RUN_TEST(test_is_empty_after_init);
    return UNITY_END();
}

16.3.4 第四步:最终实现

// ring_buffer.c (最终版本)
#include "ring_buffer.h"

void ring_buffer_init(ring_buffer_t *buf) {
    buf->head = 0;
    buf->tail = 0;
    buf->count = 0;
}

void ring_buffer_push(ring_buffer_t *buf, uint8_t data) {
    if (buf->count < RING_BUFFER_SIZE) {
        buf->buffer[buf->head] = data;
        buf->head = (buf->head + 1) % RING_BUFFER_SIZE;
        buf->count++;
    }
}

uint8_t ring_buffer_pop(ring_buffer_t *buf) {
    uint8_t data = buf->buffer[buf->tail];
    if (buf->count > 0) {
        buf->tail = (buf->tail + 1) % RING_BUFFER_SIZE;
        buf->count--;
    }
    return data;
}

uint32_t ring_buffer_count(ring_buffer_t *buf) {
    return buf->count;
}

int ring_buffer_is_full(ring_buffer_t *buf) {
    return buf->count == RING_BUFFER_SIZE;
}

int ring_buffer_is_empty(ring_buffer_t *buf) {
    return buf->count == 0;
}

关键点:注意看,pushpop 里都加了边界检查。这些检查是在「绿阶段」之后,通过新的测试用例驱动出来的。不是一开始就拍脑袋加的。

16.4 TDD的避坑指南

做了这么多年嵌入式,我用TDD踩过不少坑。分享几个给你:

  • 测试别写太大:一个测试只测一个功能点。我见过有人一个测试函数写了200行,红了都不知道哪里出问题。
  • 别跳过红阶段:有些人习惯先写代码再补测试,那就不叫TDD了。红阶段的意义在于「确认测试真的能抓到问题」。
  • 重构阶段别加新功能:重构只改结构,不改行为。想加新功能?回到红阶段去。
  • 嵌入式环境要模拟:硬件依赖用mock代替。我在项目中用Cmock处理过GPIO、UART的模拟,效果不错。

我的习惯:每次提交代码前,跑一遍全部测试。如果有一个红了,绝不提交。这个习惯帮我避免了好几次线上事故。

16.5 小结

TDD不是什么银弹,但它确实能帮你写出更可靠的代码。红绿重构循环,说白了就是「小步快跑,持续验证」。刚开始可能会觉得慢,但等你习惯了,你会发现调试时间大幅减少,代码质量明显提升。

嗯,这一章就到这里。记住:先写测试,再写代码。试试看,你会爱上这种感觉的。


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