测试哲学
就像我们通过科学方法获得关于物理宇宙行为的知识一样,我们通过一个称为"测试"的断言、观察和实验系统来获得关于软件行为的知识。
测试的本质
关于软件系统,我们可能希望了解很多事情。最常见的是,我们想知道它是否真的按照我们的意图运行。也就是说,我们编写代码时有特定的意图,当我们运行它时,它是否真的做到了这一点?
在某种意义上,测试软件是传统科学方法的反向过程。在传统科学方法中,你测试宇宙,然后使用实验结果来完善你的假设。而在软件中,如果我们的"实验"(测试)没有证明我们的假设(测试所做的断言),我们就改变我们正在测试的系统。也就是说,如果测试失败,希望这意味着我们的软件需要改变,而不是我们的测试需要改变。
不过,有时我们也需要改变测试,以正确反映我们软件的当前状态。进行这样的测试调整可能看起来像是令人沮丧和无用的时间浪费,但实际上这是这种双向科学方法的自然组成部分——有时我们了解到我们的测试是错误的,有时我们的测试告诉我们系统出了问题需要修复。
测试的关键特性
测试的目的
测试的目的是向我们提供关于系统的知识,而知识有不同的价值层次。例如,测试1+1在任何时间都等于2并不能给我们提供有价值的知识。然而,知道我依赖的API可能发生破坏性更改时我的代码仍然有效,这可能非常有用,具体取决于上下文。
一般来说,一个人必须知道自己想要什么知识,然后才能创建有效和有用的测试,然后必须适当判断该信息的价值,以了解在测试中投入时间和精力的地方。
断言的重要性
如果我们想知道某件事,为了使测试成为测试,它必须断言某事,然后告诉我们该断言的结果。人类测试人员可以进行定性断言,比如颜色是否吸引人。但自动化测试必须做出计算机可以可靠做出的断言,这通常意味着断言某个特定的定量陈述是真还是假。
我们试图通过运行测试来了解系统的某些信息——断言是真还是假就是我们获得的知识。没有断言的测试不是测试。
测试的边界
每个测试都有某些边界作为其定义的固有部分。就像你不能设计一个实验来证明物理学的所有理论和定律一样,设计一个测试来一次性验证任何复杂软件系统的所有行为将是极其困难的。如果你似乎做了这样的测试,很可能你将许多测试合并为一个,这些测试应该被分开。
在设计测试时,你应该知道它实际上在测试什么,不在测试什么。每个测试都内置了一组假设,它依赖这些假设在其边界内有效。例如,如果你正在测试依赖于数据库访问的东西,你的测试可能会假设数据库正在运行(因为其他测试已经检查了代码的那部分工作)。如果数据库没有运行,那么测试既不会通过也不会失败——它根本不提供任何知识。
这告诉我们所有测试至少有三个结果——通过、失败和未知。具有"未知"结果的测试不能说它们失败了——否则它们声称给我们知识,而实际上并没有。
测试类型
端到端测试
“端到端"测试是指你做出一个涉及通过系统逻辑的完整"路径"的断言。也就是说,你启动整个系统,在用户输入的入口点执行某些操作,并检查系统产生的结果。你不关心内部如何工作来实现这个目标,只关心输入和结果。
端到端测试的想法是,我们获得关于我们断言的完全准确的知识,因为我们正在测试一个尽可能接近"真实"和"完整"的系统。我们测试的路径上的所有交互和所有复杂性都被测试覆盖。
仅使用端到端测试的问题是,很难真正获得我们可能想要的所有关于系统的知识。在任何复杂的软件系统中,交互组件的数量以及通过代码的路径组合爆炸使得难以或不可能实际覆盖所有路径并做出我们想要的所有断言。
维护端到端测试也可能很困难,因为系统内部的微小更改会导致测试中的许多更改。
端到端测试很有价值,特别是作为完全缺乏测试的系统的初始权宜之计。它们也适合作为完整性检查,确保整个系统在组合在一起时行为正常。它们在测试套件中占有重要地位,但本身并不是获得复杂系统完整知识的良好长期解决方案。
集成测试
这是指你取系统的两个或多个完整"组件”,并专门测试它们在"放在一起"时的行为方式。组件可以是代码模块、系统依赖的库、提供数据的远程服务——基本上是可以从系统其余部分概念上隔离的任何部分。
与端到端测试相比,集成测试涉及更多的组件隔离,而不是仅仅在整个系统上作为"黑盒"运行测试。
集成测试不会像端到端测试那样严重受到测试路径组合爆炸的影响,特别是当被测试的组件简单且它们的交互简单时。如果两个组件由于交互的复杂性而难以进行集成测试,这表明也许其中一个或两个都应该为了简单性而进行重构。
集成测试本身通常也不是足够的测试方法,因为纯粹通过组件的交互来分析整个系统意味着必须测试非常大量的交互才能全面了解系统的行为。集成测试也有类似于端到端测试的维护负担,尽管没有那么严重——当一个人在一个组件的行为中做出小更改时,可能必须更新所有与之交互的其他组件的测试。
单元测试
这是指你单独取一个组件并测试它的行为是否正确。在单元测试中,你测试一个类/模块中一个函数的一个行为。你为一个类/模块创建一组单元测试,当你运行它们时,覆盖了你想要在该模块中验证的所有行为。这几乎总是意味着只测试系统的公共API——单元测试应该测试组件的行为,而不是它的实现。
理论上,如果系统的所有组件都在文档中完全定义了它们的行为,那么通过测试每个组件是否符合其文档化的行为,你实际上是在测试整个系统的所有可能行为。当你改变一个组件的行为时,你只需要更新围绕该组件的最小测试集。
显然,当系统的组件合理分离且足够简单以至于可以完全定义它们的行为时,单元测试效果最好。
现实中的测试
实际上,测试有一个在单元测试和端到端测试之间具有无限阶段的连续体。有时你介于单元测试和集成测试之间。有时你的测试介于集成测试和端到端测试之间。真实系统通常需要沿着这个连续体的各种测试,以便可靠地理解它们的行为。
测试替身(Fakes)
有些人认为,为了进行真正的"单元测试",你必须在测试中编写代码,将你正在测试的组件与系统中的所有其他组件隔离开——甚至是该组件的内部依赖项。有些人甚至认为这种"真正的单元测试"是所有测试应该追求的理想目标。
这种方法通常是错误的,原因如下:
- 测试代码的复杂性可能抵消隔离的优势
- 如果不测试真实的依赖关系,可能无法测试真实的行为
- 需要添加太多"假"对象表明系统存在设计问题
一般来说,测试之间有"重叠"并不坏。也就是说,你有用户代码公共API的测试,也有电子邮件发送代码公共API的测试。电子邮件发送代码使用真实的用户对象,因此也对用户对象进行了一点隐式"测试",但这种重叠是可以的。有重叠比错过你想要测试的区域更好。
不过,通过"假"对象进行隔离有时是有用的。必须做出判断调用,并意识到上述权衡,尝试通过"假"实例的设计尽可能减轻它们。
测试的重要属性
确定性
如果系统或其环境没有任何变化,那么测试的结果不应该改变。如果一个测试今天在我的系统上通过,但明天失败,即使我没有改变系统,那么这个测试是不可靠的。事实上,它作为测试是无效的,因为它的"失败"并不是真正的失败——它们是伪装成知识的"未知"结果。我们说这样的测试是"不稳定"或"非确定性"的。
速度
测试最重要的用途之一是开发人员在编辑代码时运行它们,以查看他们编写的新代码是否真的有效。随着测试变慢,它们对这个目的的用处越来越小。
一般来说,测试套件不应该花费太长时间,以至于开发人员在等待它完成时从工作中分心并失去注意力。现有研究表明,对于大多数开发人员来说,这需要大约2到30秒。因此,开发人员在代码编辑期间使用的测试套件应该大致需要这么长的时间来运行。
测试覆盖率
有一些工具运行测试套件,然后告诉你系统代码的哪些行实际上被测试运行了。它们说这告诉你系统的"测试覆盖率"。这些可能是有用的工具,但重要的是要记住,它们不会告诉你这些行是否真的被测试了,它们只告诉你这些代码行被运行了。如果没有关于该代码行为的断言,那么它实际上从未被测试过。
结论
有许多方法可以获得关于系统的知识,测试只是其中之一。我们也可以阅读它的代码,查看它的文档,与它的开发人员交谈等等,这些都会让我们对系统的行为产生信念。然而,测试验证了我们的信念,因此在所有这些方法中特别重要。
测试的总体目标是获得关于系统的有效知识。这个目标超越了所有其他测试原则——只要产生这个结果,任何测试方法都是有效的。然而,一些测试方法更有效——它们使创建和维护产生我们想要的所有信息的测试更容易。应该理解并适当使用这些方法,根据你的判断和它们适用于你正在测试的特定系统。