Skip to content

TDD 的三步各防什么:失败的原因、最小的通过与保持行为的整理 ​

团队约定「先写测试」。一位同事先写了测试,运行,红了;写实现,绿了;提交。代码评审时发现,那个测试一开始红是因为类还不存在、编译失败,实现写完之后它确实绿了,但断言只检查了返回值不为空。

测试驱动开发的三步「红、绿、重构」常被当成一个仪式:先写测试,再写代码,最后整理。仪式本身不保证任何东西。每一步都在防一种具体的错误,只有知道它防的是什么,才能判断这一步有没有真正做到。

本文逐步说明三步各自防止的错误、做到的标准和常见的走样。这是一篇方法文,不涉及实验;例子沿用站内运费规则的场景。

红防:永远不会失败的测试标准:失败在断言上,信息说出期望与实际的差异绿防:超出测试描述的实现标准:删掉任何一行实现,都有测试失败重构防:结构问题累积标准:前后测试结果一致,不加新行为想到新的边界情况:记下来,回到红,用新例子迫使实现变得通用编译错误、空指针不是「正确的红」;硬编码是合法的中间状态,它提示还缺一个例子
图 1 · 示意:红这一步防止一个永远不会失败的测试,要看到它因为断言失败、并且失败信息说出了差异;绿这一步防止超出测试描述的实现,删掉任何一行都应有测试失败;重构这一步防止结构问题累积,前后测试结果一致、不加新行为。新的例子迫使实现变得通用。

一、红:确认测试会因为正确的原因失败 ​

写完测试先运行,看它失败。这一步防的是一个永远不会失败的测试。

如果不先看到它失败,就无法确定它真的能检查什么:断言可能写错了对象,测试方法可能根本没有被运行(忘了 @Test、方法签名不对),参数化测试的数据源可能是空的。一个从来没有失败过的测试,和没有测试的区别只在覆盖率数字上。

「失败了」还不够,要看失败的原因是不是你期望的那个:

失败的原因说明
编译错误:类或方法不存在还没有到达断言;先写出空实现,让它因为断言而失败
空指针、未实现异常同上,失败在准备阶段
expected: 20 but was: 10正确的原因:行为还没有实现,或者实现得不对

做到的标准是:失败信息说出了期望的行为与当前行为的差异,并且这个差异就是你接下来要实现的那一点。失败信息能不能说清差异,取决于断言的写法,见从行为到场景到断言。

二、绿:用最小的改动让它通过 ​

写刚好足够让测试通过的代码。这一步防的是超出测试所描述行为的实现。

一次写出完整的实现,看起来效率更高,但多出来的那部分代码没有测试驱动它,也就没有测试保护它:「顺便」处理的边界情况、「以后可能用到」的参数,都是没有被任何测试检查过的行为。最小的通过让每一段代码都有一个测试作为存在的理由。

「最小」的常见误解是写出明显错误的硬编码(return 20;)并就此停下。硬编码是一个合法的中间状态,它的意义在于提示:还需要另一个例子来迫使实现变得通用。运费规则里,一个「10 公斤 → 20」的例子允许硬编码,加上「9 公斤 → 10」之后,硬编码就不够了,实现被迫出现了判断;再加上边界上的例子,判断的方向和临界值就被固定下来。例子的选择在这里起决定作用,见用例子把规则谈清楚。

做到的标准是:删掉任何一行实现代码,都至少有一个测试失败。

三、重构:在测试保护下整理,不改变行为 ​

测试绿了之后,整理代码:消除重复、改善命名、提取方法。这一步防的是为了快速通过而留下的结构问题累积下来。

重构的前提是行为不变,判断标准就是刚才的测试仍然全部通过。两条纪律:

  • 重构时不加新行为。想到一个新的边界情况,先记下来,回到「红」去写测试。
  • 测试也要整理,但不能同时改测试和实现。同时改两边,测试就失去了保护作用。

如果重构时测试频繁失败,而行为其实没有变,说明测试检查的是实现细节而不是行为,例如校验内部方法的调用次数。这时要改的是测试。重构中最容易被改掉的行为,见把长方法拆开,哪些行为最容易被改掉。

做到的标准是:重构前后,测试的结果一致;代码比重构前更容易读懂下一个需求要改的地方。

四、什么时候不适合 ​

TDD 在规则清楚、可以用例子表达的代码上效果最好:计算、状态转换、解析、校验。下面这些情况,先写测试的收益不明显:

  • 探索性的代码:还不知道要做什么,先写一段原型去试,确定方向之后再用测试重写。
  • 主要由框架配置组成的代码:控制器的路由、依赖注入的配置,更适合用少量集成测试检查。
  • 界面的视觉效果:用截图比对或人工检查。

不适合 TDD 并不等于不写测试,而是测试在实现之后补上。这时第一步「看它失败」仍然有用:把实现临时改错,确认测试会失败,再改回来。这正是变异测试自动化做的事,见覆盖率 100%,一个断言都没有。

五、检查清单 ​

  • 每个新测试先运行,看到它失败,并确认失败在断言上、信息说出了差异。
  • 只写让当前测试通过的代码;用新的例子迫使实现变得通用。
  • 删掉任何一行实现,都有测试失败。
  • 重构时不加新行为,不同时修改测试和实现。
  • 重构让测试失败而行为未变时,修改测试使它检查行为。
  • 不适合先写测试的代码,补写测试后用「临时改错」确认测试能失败。

参考资料

文章以 CC BY-NC-SA 4.0 授权 · 代码片段以 MIT 授权