要点概览
- 单元测试应该增加对代码正常工作的信心,允许我们记录代码应该如何工作,并帮助设计低耦合、高内聚的软件。
- 单元测试与代码库的其余部分隔离,这有助于它们快速运行、编写简单、易于理解和维护。
- 测试替身(Test Doubles)有助于促进单元测试的隔离。
- 在单元测试中大量使用 Mock Object 提供了较少的信心,即被测行为正常运行。
- Fake Object 可以使单元测试保持隔离,同时增加它测试所需行为的信心。
开发人员编写测试是为了增强对代码在生产环境正常工作的信心,记录意图并帮助应用程序设计。最近,我们看到开发人员倾向于在单元测试中大量使用测试替身,尤其是 Mock 方法。这样做是为了提高测试速度,减少测试对基础结构的依赖性,或减少测试依赖的对象数。然而,他通常伴随着低信任度,不清晰的文档描述,以及实现和测试代码之间的高耦合性等无法接受的高成本。
为了避免这些缺陷,开发人员应该考虑使用 Fake Object 而不是 Mock Object,因为前者提供了隔离优势,同时推动了实现和测试代码之间的高信任度、清晰的文档、和低耦合性。
译注,参考图书《xUnit Test Patterns》Page 133
A Fake Object (page 551) (or just “Fake” for short) is an object that replaces the functionality of the real DOC with an alternative implementation of the same functionality.
Fake Object:伪对象,是指用与真实对象相同功能的替代实现来替换真实对象功能的对象。
A Mock Object is an object that replaces a real component on which the SUT depends so that the test can verify its indirect outputs.
Mock Object:模拟对象,是指替代系统正在测试的被测组件的对象,以便测试可以验证其间接输出的对象。
背景
我们将较低级别的测试标记为单元测试,以描述这些测试在某种程度上与它们周围的代码相隔离。由于这种隔离,单元测试应该运行速度快、编写简单、易于理解和维护。
开发人员通常使测试替身来促进这种隔离。测试替身是在测试中使用的任何对象,它代替协作者。杰拉尔德·梅萨罗斯(Gerard Meszaros)在他的《xUnit Test Patterns》一书中定义了几种 Test Doubles:Dummies, Spies, Stubs, Fakes 和 Mocks. 我们将重点关注最后两个:
1、Mock Object:使用预期接收的调用及其对这些调用的响应的规范对模拟对象进行预编程。他们有一种机制来验证他们在测试期间是否接收到正确的调用,如果调用不符合他们的期望,则测试将失败。人们通常使用 Mockito、Mockk 或 GoMock 等框架来创建模拟对象。
2、Fake Object:协作者的功能实现,它们采用某种快捷方式使它们更适合在测试环境中运行。例如,开发人员可以创建内存中的数据存储来代替将数据保存到 S3 以在本地运行测试的对象。
几乎所有东西都在现代代码库的测试套件中进行模拟,并且套件在没有任何支持服务的情况下运行,这是很常见的。在这种情况下,测试套件提供了高度的信心,即系统的每个部分都可以单独正确地工作,但很少有信心它们可以一起正确地工作。稍后,我们将讨论何时不适合使用模拟。
例如,许多测试套件在测试期间模拟数据库层。测试检查是否对数据库进行了正确的调用,并使用预先编程的响应。对于这样的测试套件来说,很难让我们相信代码在生产中会正确运行,因为数据库从未被执行,并且编程到模拟中的预期调用可能不正确,更不用说使用仅模拟方法时,SQL 语句未经测试。
隔离
人们普遍认为,单元测试中的单元一词是指隔离单元。也就是说,单元测试在某种程度上与代码库的其余部分隔离。然而,在定义被隔离的单元时,意见不同。
这一定义至关重要。隔离单元形成了每个测试的范围、测试代码和生产代码之间的关系,最终形成了应用程序架构。从历史上看,已经有了广泛接受的单元测试定义,我们将在下面讨论。
测试隔离
代表经典测试方法的 Kent Beck 认为:“单元测试彼此完全隔离,每次从头开始创建测试固件。”
在这种方法中,单元测试中的单词单元是指测试本身:单元测试与其他测试是隔离的。Beck 认为“测试应该与代码的行为相关联,并与代码的结构分离。”
使用这种方法编写的测试往往很少有模拟。相反,它们使用协作对象的实例,甚至使用真实的支持基础设施(如数据库)来支持每次测试运行。
例如,一个经典测试,其主体在测试期间会使用实际的数据库进行数据库调用。测试将确保数据库在运行前处于正确的状态,并断言最终的数据库状态与预期相符。
一个使用外部 HTTP 调用的经典测试会在运行测试时进行 HTTP 调用。由于外部调用通常会降低测试的可靠性,作者可能会在本地启动一个与外部服务行为类似的 HTTP 服务器。
经典风格的测试提供了很高的置信度,证明测试代码的行为是正确的。当代码被重构时,测试往往不会改变,因为它们对合作者的外部接口知之甚少。
主体隔离
Steve Freeman 和 Nat Pryce 代表了 Mock 的测试方法,他们认为:“单元测试应该仅测试一个对象或一小撮对象。”
Freeman 认为,单元测试“对于帮助我们设计类并让我们相信它们有效非常重要,但它们并没有说明它们是否与系统的其他部分一起工作。在这种方法中,单元测试中的“单元”一词是指被测试的主体。
使用这种方法编写的测试必须使用测试替身来代替协作者,并且往往有很多模拟。他们很少使用真正的支持基础设施。这个想法是,我们应该在测试期间将测试对象与其合作者的行为隔离开来;一个对象的行为更改不应影响另一个对象的测试。开发人员还使用模拟来提高测试的速度和可靠性,使用模拟来代替缓慢或不可靠的协作者。
例如,一个模拟测试,其对象进行数据库调用,在运行测试时将模拟数据库层。受试者将与模拟数据库对象进行交互,在测试期间返回预制响应时记录传呼,并在测试结束时检查期望值。
具有进行外部 HTTP 调用的主题的模拟测试在运行测试时将使用模拟 HTTP 客户端。此客户端将在测试期间返回对 HTTP 调用的预编程响应。测试结束后,测试作者将使用模拟来检查是否进行了正确的 HTTP 调用。
这些测试往往运行得快速可靠,但它们对被测行为正常运行的置信度较低。当代码被更改或重构时,测试往往需要进行重大更改,因为他们对协作者的外部接口有深入的了解。
此外,使用模拟会增加测试代码的数量。在许多语言中,比如 Go,mockist 必须编写或生成所有模拟对象,并将代码保存在他们的代码库中。这实际上使测试套件的大小增加了一倍。即使在 Kotlin 和 Java 等在运行时生成模拟的语言中,模拟也必须在每次测试之前进行编程,并在每次测试后进行验证,这会导致需要维护更多的测试代码。
实践
为了确定实践中使用的方法,我们必须首先列举我们试图通过编写测试来实现的目标。我们希望:
- 增强对我们的代码正常工作的信心
- 记录我们的代码应该如何工作
- 帮助设计松散耦合、高度内聚的软件
考虑到这些目标,我建议从单元测试的测试隔离方法开始。如果每个测试都可靠地独立运行,同时使用尽可能多的真实协作者,我们就能实现以下目标:
信任度,因为我们的测试在类似生产的环境中运行。我们可以确信,我们的测试对象在隔离和协调中都能正常工作。我们的测试也让我们有信心,我们的受试者与他们的外部合作者一起正确地表现。当使用 Mock 方法时,我们没有同样的信心,认为我们的测试对象可以很好地协同工作。
清晰的文档,因为读者可以看到我们的代码应该如何在类似生产的环境中使用。例如,阅读测试的开发人员可以简单地检查给定操作的预期数据库状态,以了解生产中会发生什么。阅读模拟测试的开发人员必须将每个模拟的响应和期望转化为实际协作者的操作,这大大降低了清晰度和可读性。
完善的设计。重构独立于测试代码进行,因此它们相对容易执行,因此经常发生。例如,如果使用模拟方法,则通过更改对象的外部接口来重构对象,还需要为此对象重写或重新生成所有模拟。使用原始方法时,无需重写模拟,因此重构需要对测试代码进行较少的更改。这使得重构更易于执行,这意味着它更频繁地发生,并且代码库的设计会随着时间的推移而改进。
灵活变通
Martin Fowler 说过:“我不认为对外部资源使用替身是绝对的规则。如果与资源对话对你来说足够稳定和快速,那么没有理由不在单元测试中这样做。事实上,当 xunit 测试在 90 年代开始时,我们并没有试图独立系统,除非与合作者的沟通很尴尬(例如远程信用卡验证系统)”。
只要我们使用快速、可靠的协作者(无论如何这应该是我们的目标),与真正的合作者一起测试不会对我们测试的速度和可靠性产生负面影响。当情况并非如此时(例如,当通过 HTTP 与外部服务协作时),测试替身是提高测试速度和可靠性的好方法,同时牺牲了一点信心、清晰度和灵活性。
结论
在确定测试方法时,请仔细考虑单元隔离方法,并且应该根据协作对象的性质调整您的方法。归根结底,我们都想要一个快速、可靠的测试套件,让我们有信心交付,清楚地记录我们的意图,并帮助我们设计一个可扩展的系统。