测试与代码质量

TDD 测试驱动开发:Red–Green–Refactor 循环的真实面貌(实战心得)

“要先写测试?代码都还没有,到底要测试什么?”

3 分钟阅读
TDD 测试驱动开发:Red–Green–Refactor 循环的真实面貌(实战心得) 封面图

“要先写测试?代码都还没有,到底要测试什么?”

第一次接触 TDD(Test-Driven Development,测试驱动开发)时,所有人都会这么想。顺序看起来反了,还会觉得工作量莫名其妙增加了一倍。

不过,实际投入几个月后,想法会稍微改变。

TDD 是一种反复执行极短循环的开发方式:“失败的测试(Red)→ 让测试通过的最少代码(Green)→ 整理(Refactor)”。关键是以几秒到几分钟的速度快速跑完这个循环。

今天就一步步看看这个 Red–Green–Refactor 循环究竟如何运转。


TDD Red–Green–Refactor:3 个步骤重点总结 ✍️

先一眼了解整个循环。

  1. Red:先为尚不存在的功能编写测试。自然会失败。
  2. Green:只编写让测试通过所需的最少代码。不必漂亮。
  3. Refactor:在保持测试通过的同时整理代码。

把这三个步骤合在一起持续重复,就全部完成了。

不断循环这三个步骤,就是 TDD 的全部
不断循环这三个步骤,就是 TDD 的全部

重要的是不要把每个步骤定得太大。不要一次处理“完整的登录功能”,而要拆成“电子邮件为空时返回错误”这样的细小任务。

如果一个循环需要 30 分钟,那很可能只是加入了测试的开发,而不是 TDD。


Red 阶段,为什么要故意编写会失败的代码?

这是学习 TDD 时最难理解的部分。明知道会失败,为什么还要运行?

原因很简单。为了确认这个测试“确实会失败”。

如果测试刚写好就通过了,它实际上可能只是一个什么都没验证的空壳。先看到失败,就相当于测试测试本身。

举个简单的例子,假设要创建一个把两个数字相加的函数。测试会先于代码出现。

// add 函数尚不存在。因此,这个测试会失败(Red)
struct AddTests {
    @Test func 加法_1相加2是3等于() {
        #expect(add(1, 2) == 3)
    }
}

在这种状态下运行,会出现类似“cannot find ‘add’ in scope”的错误。反而是看到这个红灯让我更安心,因为目标变得明确了。

从现在开始,我要做的只有一件事:把红灯变成绿灯。

看到红灯,反而能明确下一步要做什么
看到红灯,反而能明确下一步要做什么

Green 和 Refactor,真正的实力就在这里见分晓

Green 阶段的原则是“简单到令人不好意思”。

让前面的测试通过,最快的代码就是这样。

// 让测试通过所需的最少代码(Green)
func add(_ a: Int, _ b: Int) -> Int {
    return a + b
}

看起来太理所当然了,对吧?但初学者往往会在这里担心未来,提前加入异常处理、日志和配置值。

TDD 会故意让你忍住这个冲动。规则就是:不编写当前测试没有要求的代码。

等绿灯亮起后,才进入 Refactor。这时可以调整变量名,移除重复代码。

关键是,Refactor 期间绝不添加新功能。只在保持测试通过的状态下改进结构。

如果整理时亮起红灯怎么办?把刚才修改的内容撤销即可。测试会保证它刚才还正常工作。

多亏了这张安全网,我重构时变得大胆得多。

正是这种把红灯变成绿灯的快感,让人一直做下去
正是这种把红灯变成绿灯的快感,让人一直做下去

使用 TDD,开发不会变慢吗? 🤔

这是我最常被问到的问题。实际使用后,我的答案是:“要看情况。”

我把自己感受到的差异整理成了表格。

分类 应用 TDD 之后再写测试
初期速度 稍慢
发现错误的时机 编写后立即 很久以后
重构负担
复杂逻辑 有利 不利

老实说,快速制作一个简单页面时,TDD 有时会让人觉得碍手碍脚。

相反,在支付、结算、折扣计算等条件交织的逻辑中,它确实发挥了作用。用测试逐一锁定各种情况后,即使之后大幅修改代码,也不再害怕。

归根结底,TDD 不是万能药,更像是一种“管理复杂性的工具”。


总结

如果觉得 TDD 很难,不要一开始就做得很宏大,先用一个函数跑一遍 Red–Green–Refactor。

当小小的成功循环变成习惯,你就会稍微理解为什么人们无法放弃这种方法。今天就从一个测试开始点亮红灯吧。

延伸阅读