从测试驱动开发(TDD)的概念到本质剖析

大家好,我是 ONDA 后端工程师 Gunner(정재훈),负责开发酒店运营管理解决方案。
上篇我们从多个角度梳理了软件测试的类型,也讲到了 BDD 和 TDD 这类测试工具。
有时 TDD 和测试的概念会被混用,含义变得模糊。这次我们就来理清 TDD 的概念,弄明白开发者之间曾经争论的焦点是什么,顺便聊聊对开发者来说,怎样的软件测试才算好测试。
1. 什么是测试驱动开发(Test-Driven Development, TDD)?
先说 TDD 的概念。TDD 是 Kent Beck 在 《Test-Driven Development By Example》一书¹(以下简称 TDDBE) 中介绍的软件设计方法 —— 用测试来驱动开发的进程。
*很多人把 TDD 当作测试方法,但我觉得它更接近设计方法。
TDDBE 提倡的 TDD 做法很简单:
1. 先写一个会失败的测试 2. 尽快让它通过 3. 重构
书里把这叫 "Red-Green-Refactor 循环" —— 红色代表测试失败,绿色代表通过。

TDDBE 里演示了开发 Money 类的过程,从美元货币单位开始写测试,测试通过后换其他货币单位就会失败,然后再写新的类去支持。
先写失败的测试,展示 Red-Green-Refactor 循环,Test-First 产生的这些自动化测试,既引导了 Money 类的设计方向,也在添加新功能时成为保障旧功能正常工作的安全网。
还有一点值得注意: TDDBE 里 Kent 走的每一步都很小。后来 Kent 在访谈中说过: "不是说必须迈小步,而是当设计思路不清晰时用小步找感觉,等确定了再迈大步。"
总之,TDDBE 说的 TDD 核心是 Red-Green-Refactor 循环,通过这个循环做出好设计和自动化测试。

2. TDD 在开发者中引发争论
TDD 出来后,开发者之间吵得很凶。主要分两派: "必须用 TDD 开发" 和 "TDD 不适合实际项目"。
"必须用 TDD" 这派强调 TDD 的好处 —— Test-First 带来好设计,开发同时产出自动化测试。"TDD 不适合实际" 这派则不认同 Test-First 的设计思路。
(1) Kent 为什么主张 TDD?
聊细节前先说说 Kent 提出 TDD 的背景。
Kent 主要用 Smalltalk 这门语言,还用它做了著名的 C3² 项目,也在那个项目里应用了 XP(eXtreme Programming)。Smalltalk 跑在虚拟机上,开发环境本身就是个 IDE,源代码存成一个集成镜像文件。

Smalltalk 是面向对象语言,天生适合 TDD:
- 代码浏览器里直接就能跑代码
- 大量小对象互相传消息来干活
- Smalltalk 是 MVC 模式的老祖宗,但设计 MVC 时不像现在 Java 项目那样按层分文件,而是把整个源代码存成一个镜像
- xUnit 的祖先是 SUnit
但通常的 Java Spring 项目不是这样:
- 跑代码要先启 Spring 上下文
- 相比 Smalltalk,类的拆分粗很多
- 受 DDD、六边形架构影响,分层很清晰,每层的文件也分开 —— 测试范围就扩大了
- JUnit 是从 SUnit 移植来的³
Smalltalk 找不到 "文件 → 类" 的路径,对象间交互多,需要自动化测试来确认。换句话说,Smalltalk 非常适合快速跑单元测试、即时反馈的开发方式。
(2) "TDD 已死,测试永生"
2000 年代初 TDD 概念出现后,开发者分成了积极拥护和不太认同两派。到 2014 年,Ruby on Rails(*全栈 Web 框架,几乎影响了后来所有全栈框架)的作者 DHH(David Heinemeier Hansson)在博客上发了篇文章,标题叫 "TDD 已死,测试永生"⁴,直接点燃了 TDD 大论战。
DHH 的主要观点如下:⁵
-
TDD 有它的用处,但变成了教条主义的宗教
-
TDD 过度强调单元测试,有时会带来不必要的复杂性(比如 mock)
◦ 层与层之间中继对象纠缠的复杂结构
◦ 测试里避免直接用 DB 或 I/O → 系统测试成了坏东西
◦ 服务对象、命令模式还有更糟的东西搅在一起成了丛林 → 纯粹为了测试而滥用模式和设计对象
-
TDD 可能会引导设计,但跟我的设计方式不合拍
-
我几乎不做传统意义上的单元测试 —— 把所有依赖都变成 mock、让几千个测试在几秒内跑完这种事
◦ 在 Rails 里这不算好测试
◦ 我直接测 ActiveRecord 模型,用 fixture,直接访问数据库
◦ 顶层有控制器测试,但我更喜欢用 Capybara 这类工具做高层的系统测试
-
我不是说大家都得像我这样,但 "必须 TDD 才是好开发" 这种氛围该停了
(3) Kent Beck vs. DHH,他们对 TDD 的看法
这篇文章激怒了 TDD 支持者,在网上掀起大争论。
争了一阵,最后 Martin Fowler(*《重构》作者)出面组织了 Kent Beck 和 DHH 的在线对话,当时讨论的要点总结如下:⁶ ⁷
TDD 说的单元测试是什么
-
DHH: TDD 里单元测试的定义是什么? 是不是必须用 mock? 还有 Red-Green-Refactor 循环对我来说有些适用、有些不适用。
-
Kent: 程序员做测试从来都是应该的。我用 Smalltalk 编程时把测试自动化了,试了 Test-First,发现很适合我。
-
共识: TDD 当然有用,但取决于个人风格和项目特点。
-
DHH: 很多人用大量 mock,做了糟糕的权衡。为什么要用 mock 用到吃亏?
-
Kent: 用 mock 是一种权衡,这点我同意。如果用真实对象也能快速反馈,我就用真实对象。我做 TDD 时几乎不用 mock(Smalltalk 不像 Java 那样分层,可以直接测,不需要 mock)。
另外,修改实际代码时测试代码也得改,如果实际代码变动大,对应的 mock 也得全改。测试本该让重构更容易,mock 却让重构更难,这点我有点担心。
-
DHH: 大量用 mock 的分层架构,间接引用太多,复杂度过高。
-
Kent: 这不是 TDD 造成的问题。
-
Martin: 没错,这是六边形架构的核心 "彻底与环境隔离" 导致的。
-
DHH: 同意。但我经常看到有人把隔离本身当目的来强调,就是因为 TDD 里要做单元测试。
应用六边形架构的人说可以把(领域模型)做成终端应用或命令行。我想问: 真会有这种情况吗?
Repository 也一样。他们说可以把访问数据库的实现换成内存数据库或 Web 服务,真会换吗?
-
Kent: 这也不是 TDD 的问题,而是设计问题,或者说多快能拿到反馈的问题。设计上的问题就该好好考虑设计。
-
共识: 快速反馈很重要,TDD 代替不了 QA。
所有代码都该做 TDD 吗? 测试会不会太多或者没必要?
-
Kent: 我也不是总做 TDD。人们写测试不是为了付出成本,而是为了获得足够的信心。
-
Martin: 过度测试肯定存在。我把这行代码注释掉,测试会挂吗? 重要的是测可能挂的场景。getter 这种能信任的地方不用测。
· 代码改不动? → 测试不够或者测试不好的信号 · 改代码比改测试还难? → 测试太多的信号
TDD 哪些方面有用?
- Kent: TDD 把问题拆小,给人信心。
- Martin: TDD 有适合的领域,也有不适合的。
- DHH: 同意有适合的领域。我的经验是 MVC Web 应用很多时候不适合。但即便不适合 TDD,自动化的自测试也必不可少,那才是 TDD 的重要价值。
最后结论是: TDD 是好工具,但也有点看人,不是所有领域都适用。
💡关于 TDD 是个人选择的问题,看看 Clojure 作者 Rich Hickey 怎么说:
Rich 把这叫 "护栏式编程","说有测试才敢改代码,谁这么干? 谁开车撞着护栏走?" 批评 TDD 的 Red-Green-Refactor 循环。他不是反对 TDD 本身,而是强调逻辑分析能力的重要性(深思熟虑的分析应该体现在程序设计里)。
意思是: 做了 TDD 或者测试全过了,不等于程序就是好的。他讽刺类型检查器时也能看出这一点。⁸
Rich 也承认自动化自测试的必要性,但在遭到 TDD 支持者攻击后说: "如果你用的工具或方法论被批评不适合某个场景,你觉得像被攻击,那你得放松一下" —— 方法论只是工具而已。
(4) TDD 论战持续至今说明了什么
虽然不像以前那么激烈,但 TDD 的争论为什么还在继续?
仔细看争论内容,很多时候是把不属于 TDD 的好处当成了 TDD 的好处。比如:
- 必须 TDD 才能得到自动化自测试? 不是。
- 必须 TDD 才能得到回归测试? 不是。
- 必须 TDD 才能做出好设计(*什么是好设计又是另一个话题)? 不是。
- TDD 把需求明确表达成代码,但必须 TDD 才行? 不是。
TDD 是以 Red-Green-Refactor 循环进行的设计方法,确实有用,但不一定适合所有人或所有项目。而且就像 Kent 在论战里说的,mock 跟 TDD 没关系。所以要把设计方法 "TDD" 的效用和 "测试" 的效用明确分开。
3. 对开发者来说,什么是好测试?
讨论什么是好测试之前,先要承认一个前提: 测试也是一种权衡。写测试、维护测试都要开发者的时间。
测试确实对开发有帮助,但就像 Martin Fowler 在对话里说的,既有不够的情况,也有过头的时候。那对开发者来说,什么是好测试?
(1) 该测的代码 vs. 不该测的代码
好测试是测容易出错的部分。
容易出错的代码
- 很多地方引用的代码
- 核心逻辑代码
- 将来可能会改的代码
- QA 或实际上线后出过问题的代码
光写单元测试很难判断哪些代码容易出错。这时可以用 BDD —— 在 Given/When/Then 的上下文里思考测试用例,自然就能看出这个对象跟其他对象怎么关联。
反过来,判断哪些部分可以信任、决定不测,也很重要。
不易出错的代码
- 用的地方不多的代码
- 清晰简单的代码
- 库里的功能
不过选择不测哪些代码没有绝对标准。比如一般不测库代码的团队,如果某个库频繁更新、接口或行为经常变,那用这个库的代码最好加上测试。
换句话说,判断不测哪些代码,要根据开发者的实际情况充分考虑。
(2) mock,一定要用吗?
看测试相关的争论,提到 mock 的特别多。
TDD 这类方法论基本都讲单元测试,单元测试要在独立环境里快速验证测试对象单元的行为 —— 按单元测试的定义,测试单元需要的其他层应该用 mock。
这话有道理,但 TDD 论战里 DHH 强调了 mock 的弊端,Kent 也说做 TDD 时几乎不用 mock。Kent 甚至担心 mock 会让重构变难。
用 mock 的根本原因是 "在独立环境里快速测试"。
但没有规定必须用 mock 才对。它只是构造测试的一个工具。
- 独立环境: 面向对象系统里多个对象协作干活,用 mock 做的独立单元测试,真能好好测到测试对象吗?
- 速度: 不用 mock、直接用真实的层对象,如果速度也不慢,用真实对象有问题吗?
(3) 自动化的自测回归测试
"好测试" 的另一个特征是自动化的自测回归测试。逐个看每个词:
- 自动化(automated): 太理所当然所以没怎么提,但非常重要。如果跑测试还要额外操作,测试就成负担了。
- 自测(self-checking): 功能要能自己检查是否正常工作。测试结果需要人来判断的话,(显然)那也是活儿。
- 回归(regression): 能确认以前好好工作的功能现在还能不能正常工作。
自动化的自测回归测试不能保证所有代码的安全。但如果深思熟虑后判断需要测试的部分被测试覆盖了,至少在那部分可以有信心。
再补充一点: 做 TDD 时自然会产生自动化的自测回归测试,但那不是 TDD 追求的目标。自动化的自测回归测试可以脱离 TDD 单独做。
也就是说,TDD 是一种有用的设计方法,最重要的是开发者判断哪些部分容易出错、精心编写自动化自测试的能力。
其实开发者很早以前就一直在做测试。看日志、看屏幕、看打印机输出的值是否符合预期,有时写个 shell 脚本跑程序,用眼睛或 shell 命令确认程序输出是否正确。Kent 用 TDD 和 SUnit 把这些做法规范化了,也带来了很多争论。但争论中没人质疑自动化自测试的效用 —— 优秀的开发者早就用某种方式在做测试了。
参考
1. 译本: http://www.yes24.com/Product/Goods/12246033,原书: Test Driven Development: By Example: Beck, Kent: 8601400403228: Amazon.com: Books
2. Chrysler Comprehensive Compensation System - Wikipedia
3. JUnit Was Born on a Plane! – Tesla Tales (wordpress.com)
4. TDD is dead. Long live testing. (DHH)
5. "TDD는 죽었다" - Rails를 만든 DHH의 글 (sangwook.github.io)
6. TDD 공부 중. Is TDD dead? (junho85.pe.kr)
7. [한글화 프로젝트] TDD는 죽었는가? (tistory.com)
8. "Simple Made Easy" - Rich Hickey (2011) (youtube)
⚠️ 未经许可禁止转载与再分发。引用时请注明出处 ONDA(온다)。