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