在编写代码之前,我不知道该测试什么
对于 TDD 学习者来说,一个巨大的挫折是在没有事先编写生产代码的情况下知道要测试什么。这是一个同样有其合理性的批评。在这种情况下,一旦我们理解了开发者面临的问题,就会发现解决方案是一种可以应用到我们工作流程中的技术,而不是思维方式的重新构建。
理解从测试开始的困难
在某种程度上,思考如何实现代码是很自然的。毕竟,这是我们学习的方式。我们写下 System.out.println("Hello, World!");
,而不是围绕这行著名的代码设计某种结构。小型程序和实用工具在我们以线性代码编写时运行得很好,就像一份指令清单一样。
然而,随着程序规模的增大,我们开始面临困难。我们需要帮助将代码组织成易于理解的模块。这些模块需要易于理解,我们希望它们是自文档化的,并且能够轻松知道如何调用它们。随着代码规模的增加,这些模块的内部细节变得不那么重要,而它们的外部结构——即模块之间的交互方式——变得越来越重要。
举个例子,假设我们正在编写一个 TextEditorWidget
类,并且我们希望实时检查拼写。我们找到了一个包含 SpellCheck
类的库。我们并不太关心 SpellCheck
类内部是如何工作的,我们只关心如何使用这个类来检查拼写。我们想知道如何创建该类的对象,需要调用哪些方法来完成拼写检查任务,以及如何访问输出结果。
这种思维方式正是软件设计的定义——组件如何组合在一起。如果我们希望维护代码库,随着代码规模的增加,强调设计是至关重要的。我们使用封装将数据结构和算法的细节隐藏在函数和类内部,并提供一个简单易用的编程接口。
克服先写生产代码的需求
TDD(测试驱动开发)为设计决策提供了脚手架。通过在编写生产代码之前编写测试,我们定义了希望被测代码如何被创建、调用和使用。这帮助我们快速评估设计决策的效果。如果测试显示创建对象很困难,这表明我们的设计应该简化创建步骤。同样,如果对象难以使用,我们应该简化编程接口。
然而,当我们还不清楚合理的设计应该是什么样子时,该如何应对?这种情况在我们使用新库、与团队其他成员的新代码集成,或处理大型用户故事时很常见。
为了解决这个问题,我们使用 探索性代码(spike)——一段简短的代码,足以证明设计的可行性。在这个阶段,我们并不追求最干净的代码,也不覆盖许多边缘情况或错误条件。我们的目标是探索对象和函数的可能组合,以形成一个可信的设计。一旦我们有了这样的设计,我们会记录一些设计笔记,然后删除这段代码。现在,我们知道合理的设计是什么样子了,就能更好地知道该编写哪些测试。接下来,我们可以使用常规的 TDD 来驱动设计。
有趣的是,当我们以这种方式重新开始时,通常会得到一个比探索性代码更好的设计。TDD 的反馈循环帮助我们发现新的方法和改进点。
我们已经看到,在测试之前开始实现代码是很自然的,而我们可以通过 TDD 和探索性代码来创建一个更好的流程。我们在 最后负责任时刻(last responsible moment) 做出决策——这是我们在明知会做出不可逆转的次优决策之前,尽可能推迟决策的时间点。当有疑问时,我们可以通过探索性代码——一段用于学习然后丢弃的实验性代码——来了解更多解决方案的可能性。