第16章 测试驱动开发:TDD流程、红绿重构循环、实战案例
测试驱动开发,圈里人常说的TDD,其实不是什么高深莫测的玄学。说白了就是「先写测试,再写代码」。我第一次接触这个概念是在一个嵌入式项目里,当时被bug追着跑,改一个地方崩三个地方。后来一位老同事扔给我一句话:「你试试先写测试,再写代码。」嗯,这一试,就回不去了。
16.1 TDD的核心流程
TDD的流程,我习惯用一个循环来概括:红 → 绿 → 重构。这三个字,就是TDD的全部秘密。
红绿重构循环
- 红(Red):先写一个会失败的测试。这时候代码还没写,测试当然跑不过,显示红色。
- 绿(Green):写最少的代码,让测试通过。别想太多,能跑就行,显示绿色。
- 重构(Refactor):在测试保护下,优化代码。保证绿色不变,但代码更干净。
你想想看,这个循环像不像一个「安全网」?每次改动都有测试兜底,心里踏实。
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 字段,直接用 head 和 tail 算出来:
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;
}
关键点:注意看,push 和 pop 里都加了边界检查。这些检查是在「绿阶段」之后,通过新的测试用例驱动出来的。不是一开始就拍脑袋加的。
16.4 TDD的避坑指南
做了这么多年嵌入式,我用TDD踩过不少坑。分享几个给你:
- 测试别写太大:一个测试只测一个功能点。我见过有人一个测试函数写了200行,红了都不知道哪里出问题。
- 别跳过红阶段:有些人习惯先写代码再补测试,那就不叫TDD了。红阶段的意义在于「确认测试真的能抓到问题」。
- 重构阶段别加新功能:重构只改结构,不改行为。想加新功能?回到红阶段去。
- 嵌入式环境要模拟:硬件依赖用mock代替。我在项目中用Cmock处理过GPIO、UART的模拟,效果不错。
我的习惯:每次提交代码前,跑一遍全部测试。如果有一个红了,绝不提交。这个习惯帮我避免了好几次线上事故。
16.5 小结
TDD不是什么银弹,但它确实能帮你写出更可靠的代码。红绿重构循环,说白了就是「小步快跑,持续验证」。刚开始可能会觉得慢,但等你习惯了,你会发现调试时间大幅减少,代码质量明显提升。
嗯,这一章就到这里。记住:先写测试,再写代码。试试看,你会爱上这种感觉的。
公众号:蓝海资料掘金营,微信deep3321