TDD 的三步各防什么:失败的原因、最小的通过与保持行为的整理
团队约定「先写测试」。一位同事先写了测试,运行,红了;写实现,绿了;提交。代码评审时发现,那个测试一开始红是因为类还不存在、编译失败,实现写完之后它确实绿了,但断言只检查了返回值不为空。
测试驱动开发的三步「红、绿、重构」常被当成一个仪式:先写测试,再写代码,最后整理。仪式本身不保证任何东西。每一步都在防一种具体的错误,只有知道它防的是什么,才能判断这一步有没有真正做到。
本文逐步说明三步各自防止的错误、做到的标准和常见的走样。这是一篇方法文,不涉及实验;例子沿用站内运费规则的场景。
一、红:确认测试会因为正确的原因失败
写完测试先运行,看它失败。这一步防的是一个永远不会失败的测试。
如果不先看到它失败,就无法确定它真的能检查什么:断言可能写错了对象,测试方法可能根本没有被运行(忘了 @Test、方法签名不对),参数化测试的数据源可能是空的。一个从来没有失败过的测试,和没有测试的区别只在覆盖率数字上。
「失败了」还不够,要看失败的原因是不是你期望的那个:
| 失败的原因 | 说明 |
|---|---|
| 编译错误:类或方法不存在 | 还没有到达断言;先写出空实现,让它因为断言而失败 |
| 空指针、未实现异常 | 同上,失败在准备阶段 |
expected: 20 but was: 10 | 正确的原因:行为还没有实现,或者实现得不对 |
做到的标准是:失败信息说出了期望的行为与当前行为的差异,并且这个差异就是你接下来要实现的那一点。失败信息能不能说清差异,取决于断言的写法,见从行为到场景到断言。
二、绿:用最小的改动让它通过
写刚好足够让测试通过的代码。这一步防的是超出测试所描述行为的实现。
一次写出完整的实现,看起来效率更高,但多出来的那部分代码没有测试驱动它,也就没有测试保护它:「顺便」处理的边界情况、「以后可能用到」的参数,都是没有被任何测试检查过的行为。最小的通过让每一段代码都有一个测试作为存在的理由。
「最小」的常见误解是写出明显错误的硬编码(return 20;)并就此停下。硬编码是一个合法的中间状态,它的意义在于提示:还需要另一个例子来迫使实现变得通用。运费规则里,一个「10 公斤 → 20」的例子允许硬编码,加上「9 公斤 → 10」之后,硬编码就不够了,实现被迫出现了判断;再加上边界上的例子,判断的方向和临界值就被固定下来。例子的选择在这里起决定作用,见用例子把规则谈清楚。
做到的标准是:删掉任何一行实现代码,都至少有一个测试失败。
三、重构:在测试保护下整理,不改变行为
测试绿了之后,整理代码:消除重复、改善命名、提取方法。这一步防的是为了快速通过而留下的结构问题累积下来。
重构的前提是行为不变,判断标准就是刚才的测试仍然全部通过。两条纪律:
- 重构时不加新行为。想到一个新的边界情况,先记下来,回到「红」去写测试。
- 测试也要整理,但不能同时改测试和实现。同时改两边,测试就失去了保护作用。
如果重构时测试频繁失败,而行为其实没有变,说明测试检查的是实现细节而不是行为,例如校验内部方法的调用次数。这时要改的是测试。重构中最容易被改掉的行为,见把长方法拆开,哪些行为最容易被改掉。
做到的标准是:重构前后,测试的结果一致;代码比重构前更容易读懂下一个需求要改的地方。
四、什么时候不适合
TDD 在规则清楚、可以用例子表达的代码上效果最好:计算、状态转换、解析、校验。下面这些情况,先写测试的收益不明显:
- 探索性的代码:还不知道要做什么,先写一段原型去试,确定方向之后再用测试重写。
- 主要由框架配置组成的代码:控制器的路由、依赖注入的配置,更适合用少量集成测试检查。
- 界面的视觉效果:用截图比对或人工检查。
不适合 TDD 并不等于不写测试,而是测试在实现之后补上。这时第一步「看它失败」仍然有用:把实现临时改错,确认测试会失败,再改回来。这正是变异测试自动化做的事,见覆盖率 100%,一个断言都没有。
五、检查清单
- 每个新测试先运行,看到它失败,并确认失败在断言上、信息说出了差异。
- 只写让当前测试通过的代码;用新的例子迫使实现变得通用。
- 删掉任何一行实现,都有测试失败。
- 重构时不加新行为,不同时修改测试和实现。
- 重构让测试失败而行为未变时,修改测试使它检查行为。
- 不适合先写测试的代码,补写测试后用「临时改错」确认测试能失败。
参考资料
- Kent Beck,《Test-Driven Development: By Example》
- Kent Beck,Canon TDD
- Martin Fowler,TestDrivenDevelopment