在设计一个新的软件项目时,常常会面临如何构建它的众多选择。核心抽象应该是什么?它们之间应该如何相互作用?
在这篇文章中,我想主张一个我发现对回答或影响许多这些问题非常有用的设计启发式:
优化你的代码以便于测试
具体来说,这意味着当你编写新代码时,在设计它及其与系统其他部分的关系时,问自己这个问题:“我将如何测试这段代码?我将如何编写自动化测试来验证这段代码的正确性,同时对环境或系统的其他部分做出最小的无关假设?” 如果你对这个问题没有好的答案,就重新设计你的抽象或接口,直到你有一个好的答案。
我发现这个启发式在两个不同的方面都很有价值,我将在这里讨论这两个方面。
你会得到好的测试#
这一点或许显而易见:如果你以拥有良好测试为目标,你很可能会得到好的测试。
然而,我认为值得多说一点。
首先,我想强调在一个有良好测试的代码库中工作是多么美妙。验证更改的过程很简单:只需运行测试。无需设置复杂的开发环境,也无需手动或交互地探测系统;只需 make test
,就可以获得,如果不是保证,至少是对你的代码执行你认为它执行的操作且没有破坏任何重要内容的高度信心。
在大型和成熟的软件系统中,进行更改最困难的部分不是更改本身,而是以不回归其他重要行为的方式进行更改。因此,良好、快速的测试能够迅速提供基本保证,因而是巨大的生产力提升。
其次,为了真正实现良好测试的好处,你需要能够快速运行测试 —— 或者至少是覆盖你更改的测试子集,以保持快速的开发周期。
为了在系统扩展时保持这一特性,你需要有良好的 单元 测试。关于 “单元”、“功能”、“集成” 和其他类型测试之间的界限,有很多宗教式的争论;在这里,我所说的 “单元” 测试是指以最小的依赖或调用其他代码的方式,测试代码库中某个特定模块或组件的测试。
你还总是需要一些集成测试,这些测试覆盖应用程序的端到端,以便发现组件之间不可避免的微妙交互。但主要依赖单元测试带来了显著的优势:
- 单元测试速度快:通过测试有限的代码,它们比必须调用整个应用程序的测试运行得更快。
- 更重要的是,单元测试可扩展:在一个主要由单元测试组成的代码库中,测试速度应该与应用程序的大小线性扩展。随着更多功能的端到端测试,你的扩展速度可能接近于平方,因为每个组件都需要测试,而这些测试又恰好会测试几乎每个其他组件。
- 由于单元测试与清晰的代码片段相关联,因此很容易仅运行与特定更改对应的测试,从而为开发提供更快的反馈循环。
然而,良好的单元测试并不是偶然发生的。没有良好的 “单元”,很难有良好的单元测试:具有狭窄且定义明确接口的代码部分,可以在这些接口上进行测试。
拥有这样的逻辑单元的最佳方法,进而允许优秀的测试,进而允许快速反馈循环,同时保持一个成熟的项目,就是直接这样做:以此为目标编写代码。
你会得到更好的代码#
编写考虑测试的代码的第二个原因是,它实际上会产生更好的代码!
让我们考虑一些易于测试的代码应该具备的特征:
偏好纯函数而非不可变数据#
纯函数和不可变数据结构是 令人愉悦 的易于测试:你可以仅创建一张(示例输入,预期输出)对的表。它们通常也容易用像 QuickCheck 这样的工具进行模糊测试,因为输入易于描述。
小模块与明确的接口#
如果一段代码具有小而明确的接口,我们可以根据该接口编写黑箱测试,测试描述的接口契约,而不必过于关心模块的内部实现或我们系统的其他部分。
IO 和计算的分离#
IO 通常比纯代码更难测试。因此,可测试的系统将其与代码的纯逻辑隔离,以便可以单独测试。
显式声明依赖关系#
隐式从全局环境中提取数据库名称的代码比接受句柄作为参数的代码更难测试 —— 你可以在测试过程中用不同的句柄调用后者,以便每次都在干净的数据库上进行测试,或在多个线程中同时测试,或根据需要进行其他操作。
一般来说,可测试的代码在某个时刻将依赖关系作为显式参数接受,而不是假设它们隐式可用。
如果我查看这些特征,我发现它们也是 良好、结构良好的代码 的特征!通过考虑测试来架构系统,我们通常会得到一个比否则更好地分解的系统。
此外,通过间接地接近这些属性 —— 通过测试的视角 —— 我们使它们更加具体。无休止地争论软件系统的正确模块、接口和结构是什么是很容易的;但面对 “什么使得充分测试这段代码变得容易?” 这一具体框架时,更容易评估我们的选择,并决定何时已经足够。
结论#
对于复杂的软件系统来说,没有什么比以信心进行更改的能力更重要,而高质量的自动化测试是我们为此目的拥有的最重要工具之一。良好的测试不是偶然发生的,甚至不是通过蛮力努力得来的:它们是通过设计而来,因为应用程序是为了使它们能够存在而编写的。
在编写软件时,始终问自己 “我将如何测试这款软件的正确性?” 并愿意为此目标进行设计。作为回报,你将获得一个你可以 —— 并且能够 —— 更加自信的系统,以及一个结构更好的系统。