单元测试被高估了 - tyrrrz


测试在现代软件开发中的重要性怎么强调都不为过。交付一个成功的产品不是你做一次就忘记的事情,而是一个不断重复的过程。随着每一行代码的更改,软件必须保持功能状态,这意味着需要进行严格的测试。
随着时间的推移,随着软件行业的发展,测试实践也日趋成熟。逐渐走向自动化,测试方法影响了软件设计本身,催生了诸如测试驱动开发之类的口头禅,强调了依赖倒置等模式,并普及了围绕它构建的高级架构。
如今,自动化测试已深深嵌入我们对软件开发的认知中,很难想象其中一个没有另一个。而且由于这最终使我们能够在不牺牲质量的情况下快速生产软件,因此很难说这不是一件好事。
然而,尽管有许多不同的方法,现代“最佳实践”主要推动开发人员专门进行单元测试。
这种方法的好处通常得到以下论点的支持:单元测试在开发过程中提供最大价值,因为它们能够快速捕获错误并帮助实施促进模块化的设计模式。这个想法已被广泛接受,以至于“单元测试”一词现在在某种程度上与一般的自动化测试混为一谈,失去了部分含义并导致混淆。
当我还是一个经验不足的开发人员时,我相信遵循这些“最佳实践”,因为我认为这会使我的代码变得更好。由于涉及抽象和模拟的所有仪式,我并不是特别喜欢编写单元测试,但毕竟这是推荐的方法,所以我应该更了解谁。
直到后来,随着我进行了更多的实验并构建了更多的项目,我才开始意识到有更好的方法来进行测试,而专注于单元测试在大多数情况下完全是浪费时间。
 

积极普及的“最佳实践”通常倾向于在他们周围表现出货物崇拜,诱使开发人员应用设计模式或使用特定方法,而不给他们急需的重新考虑。在自动化测试的背景下,当谈到我们行业对单元测试的不健康痴迷时,我发现这很普遍。
在本文中,我将分享我对这种测试技术的观察,并讨论为什么我认为它效率低下。我还将解释我目前在开源项目和日常工作中使用哪些方法来测试我的代码。
  
单元测试的谬误
单元测试,顾名思义,围绕“单元”的概念展开,它表示较大系统中非常小的孤立部分。对于一个单元是什么或它应该有多小没有正式的定义,但大多数人认为它对应于模块的单个功能(或对象的方法)。
通常,当编写代码时没有考虑到单元测试,可能无法完全隔离地测试某些功能,因为它们可能具有外部依赖关系。为了解决这个问题,我们可以应用依赖倒置原则,用抽象替换具体的依赖。然后,这些抽象可以用真实或虚假的实现代替,具体取决于代码是正常执行还是作为测试的一部分。
除此之外,单元测试应该是纯粹的。例如,如果一个函数包含将数据写入文件系统的代码,则该部分也需要抽象出来,否则验证这种行为的测试将被视为集成测试,因为它的覆盖范围扩展到单元与文件系统。
考虑到上述因素,我们可以推断单元测试仅对验证给定函数内部的纯业务逻辑有用。它们的范围没有扩展到测试副作用或其他集成,因为这属于集成测试领域。

  • 单元测试的目的有限

重要的是要理解任何单元测试的目的都非常简单:在隔离范围内验证业务逻辑。根据您打算测试的交互,单元测试可能是也可能不是适合这项工作的工具。
例如,对使用漫长而复杂的数学算法计算太阳时的方法进行单元测试是否有意义?很可能,是的。
对向 REST API 发送请求以获取地理坐标的方法进行单元测试是否有意义?很可能,不是。
如果您将单元测试本身视为一个目标,您很快就会发现,尽管付出了很多努力,但大多数测试仍无法为您提供所需的信心,这仅仅是因为它们测试了错误的东西。在许多情况下,测试与集成测试更广泛的交互比专门关注单元测试更有益。
有趣的是,一些开发人员最终确实在这种情况下编写了集成测试,但仍然将它们称为单元测试,主要是由于围绕概念的混淆。尽管可以争辩说可以任意选择单元大小并且可以跨越多个组件,但这使得定义非常模糊,最终只是使该术语的整体使用完全无用。
 
  • 单元测试导致更复杂的设计

支持单元测试的最流行的论点之一是它强制您以高度模块化的方式设计软件。这是建立在这样一个假设之上的,即当代码被分成许多较小的组件而不是几个较大的组件时,它更容易推理代码。
但是,它通常会导致相反的问题,即功能最终可能会变得不必要地分散。这使得评估代码变得更加困难,因为开发人员需要扫描构成应该是单个内聚元素的多个组件。
此外,实现组件隔离所需的抽象的大量使用创建了许多不需要的间接。尽管抽象本身是一种非常强大和有用的技术,但抽象不可避免地会增加认知复杂性,从而使推理代码变得更加困难。
通过这种间接方式,我们最终也会失去某种程度的封装,否则我们可以保持这种封装。例如,管理单个依赖项生命周期的责任从包含它们的组件转移到其他一些不相关的服务(通常是依赖项容器)。
一些基础设施复杂性也可以委托给依赖注入框架,从而更容易配置、管理和激活依赖项。但是,这会降低可移植性,这在某些情况下可能是不可取的,例如在编写库时。
归根结底,虽然很明显单元测试确实会影响软件设计,但这是否真的是一件好事还存在很大争议。
 
  • 单元测试很昂贵

从逻辑上讲,假设由于它们很小且孤立,单元测试应该非常容易和快速地编写是有道理的。不幸的是,这只是另一个似乎相当流行的谬论,尤其是在经理中。
尽管前面提到的模块化架构诱使我们认为各个组件可以彼此分开考虑,但单元测试实际上并没有从中受益。事实上,单元测试的复杂性仅与单元具有的外部交互的数量成正比增长,因为您必须做所有工作来实现隔离,同时仍然执行所需的行为。
本文前面展示的示例非常简单,但在实际项目中,安排阶段跨越许多长行并不罕见,只是为单个测试设置前提条件。在某些情况下,被模拟的行为可能非常复杂,几乎不可能将其解开以弄清楚它应该做什么。
除此之外,单元测试在设计上与他们正在测试的代码非常紧密地耦合,这意味着任何进行更改的努力都会有效地加倍,因为测试套件也需要更新。更糟糕的是,很少有开发人员似乎觉得这样做是一项诱人的任务,通常只是把它交给团队中更年轻的成员。
  
  • 单元测试依赖于实现细节

基于模拟的单元测试的不幸含义是,用这种方法编写的任何测试都是固有的实现意识。通过模拟一个特定的依赖关系,你的测试变得依赖于被测试的代码如何消耗这个依赖关系,而这个依赖关系并没有被公共接口所规范。

这种额外的耦合往往会导致意想不到的问题,在这种情况下,看似非破坏性的变化会导致测试开始失败,因为模拟变得过时了。这可能是非常令人沮丧的,并最终使开发人员不愿意尝试重构代码,因为永远不清楚测试中的错误是来自于实际的回归还是由于依赖某些实现细节。

单元测试有状态的代码可能更加棘手,因为可能无法通过公开暴露的接口来观察变异。为了解决这个问题,你通常会注入间谍,这是一种模拟的行为,记录函数被调用的时间,帮助你确保单元正确使用它的依赖关系。

当然,当你不仅依赖一个特定的函数被调用,而且还依赖它发生了多少次或传递了哪些参数时,测试就变得与实现更加耦合了。以这种方式编写的测试,只有在内部细节不发生变化的情况下才有用,而这是一个非常不合理的期望。

过分依赖实现细节也会使测试本身变得非常复杂,考虑到为了模拟一个特定的行为需要进行多少设置,特别是当交互不是那么微不足道或者有很多依赖关系时。当测试变得如此复杂,以至于他们自己的行为难以推理时,谁会去写测试来测试?

 

  • 单元测试不能测试用户行为

无论你开发的是什么类型的软件,其目标都是为最终用户提供价值。事实上,我们写自动化测试的主要原因是为了确保没有意外的缺陷会削弱这种价值。

在大多数情况下,用户通过一些顶层界面(如用户界面、CLI或API)来使用软件。虽然代码本身可能涉及许多抽象层,但对用户来说,唯一重要的是他们能实际看到并与之互动的那一层。

如果在系统的某些部分有一个错误,这甚至并不重要,只要它没有浮现在用户面前,并且不影响提供的功能。反过来说,如果在用户界面上有一个缺陷,使我们的系统实际上毫无用处,那么我们可能对所有的底层部分都有全面的覆盖,这也没有什么区别。

当然,如果你想确保某样东西能正常工作,你就必须检查那件确切的东西,看看它是否能做到。在我们的例子中,获得对系统信心的最好方法是模拟一个真实的用户如何与顶层界面互动,看看它是否按照预期正常工作。

单元测试的问题是,它们与此完全相反。因为我们总是在处理用户不直接交互的孤立的小块代码,所以我们从未测试过实际的用户行为。

做基于模拟的测试使这种测试的价值受到了更大的质疑,因为我们系统中本来要使用的部分被替换成了模拟,进一步拉开了模拟环境与现实的距离。我们不可能通过测试与用户体验不相似的东西来获得用户会有一个顺利体验的信心。
 

现实驱动的测试
最好编写尽可能高度集成的测试,同时保持它们的速度和复杂性合理。
根据行为线索而不是代码的内部结构来划分测试是一个好主意。
 

  • 纯度与非纯度分离

基于纯度的代码分离的基本原则非常重要,但经常被忽视。如果使用得当,它可以指导软件设计,在可读性、可移植性和单元测试方面带来好处。
纯度是一个非常有用的概念,因为它可以帮助我们理解某些操作如何使我们的代码具有不确定性、难以推理以及单独测试很麻烦。不纯的交互本身并不坏,但它们施加的约束本质上具有传染性,并可能传播到应用程序的其他部分。
纯不纯分离原则旨在通过将杂质与代码的其余部分分离,将它们限制在最低限度。最终,目标是将所有非纯操作推向系统的最外层,同时保持领域层完全由纯函数组成。
以这种方式设计软件会导致类似于管道而不是层次结构的体系结构,这有利于编程的功能风格。根据项目的不同,这可能有助于更清楚地表达数据流以及其他有用的好处。
但是,这并不总是可行的,并且在某些情况下,提取纯代码会以严重降低内聚性为代价。

 
概括
单元测试是一种流行的软件测试方法,但主要是出于错误的原因。它通常被吹捧为开发人员测试代码同时执行最佳设计实践的一种有效方式,但许多人认为它既累赘又肤浅。
重要的是要了解开发测试并不等同于单元测试。主要目标不是编写尽可能独立的测试,而是获得对代码根据其功能要求工作的信心。并且有更好的方法来实现这一目标。
从长远来看,编写由用户行为驱动的高级测试将为您提供更高的投资回报,而且并不像看起来那么难。找到一种对您的项目最有意义的方法并坚持下去。
以下是主要内容:

  1. 批判性思考并挑战最佳实践
  2. 不要依赖测试金字塔
  3. 按功能分离测试,而不是按类、模块或范围
  4. 以最高水平的集成为目标,同时保持合理的速度和成本
  5. 避免为了可测试性而牺牲软件设计