不存在单元测试

2025-06-08

不存在单元测试

“即使按照大多数软件术语的模糊标准来看,‘单元测试’和‘集成测试’这两个术语也一直比较模糊。”

——马丁·福勒

“...根本不存在单元测试”

——Michael Belivanakis

什么是单元测试?

我在一个流行的搜索引擎中输入了上述问题,得到的前三个结果如下(重点是我加的)

单元测试是一段代码,用于验证较小的、独立的应用程序代码块(通常是函数或方法)的准确性。-- aws.amazon.com

单元测试是一种测试单元的方法——单元是系统中逻辑上可以隔离的最小代码片段。在大多数编程语言中,单元可以是函数、子程序、方法或属性。—— smartbear.com

单元被定义为被测系统 (SUT) 所展现的单一行为,通常对应一项需求。虽然它可能暗示它是一个函数或模块(在过程式编程中),或是一个方法或类(在面向对象编程中),但这并不意味着函数/方法、模块或类总是对应于单元。从系统需求的角度来看,只有系统的边界才有意义,因此只有外部可见的系统行为的入口点才定义单元。—— Kent Beck 维基百科

我在现代软件测试中经常看到的一个问题是,我们过于倾向于第二种定义:单元是“系统中可以逻辑隔离的最小代码片段”。“可以”这个词在这句话中意义重大我们几乎可以逻辑地隔离任何东西。

“单位就是文件吗?”

——“绝对不是”,我能听到你说。

“假设一个面向对象的程序,那么类怎么样?”

——“可能不会”,你不太确定地说道。

“方法怎么样?”

——“可能”,比较有把握,同意上面的前两个结果。

“如果该方法有 300 行代码怎么办?”

——“哦,是的,你可能应该把它分解成更小的方法。”

假设我们这样做。让我们把 300 行的方法拆分成 10 个方法,每个方法 30 行。有些计算机科学教授似乎会教学生,这是控制函数长度的一个很好的经验法则

// before
def original(x: String, y: Int): Boolean = {

  // ...
  // hundreds of lines of code
  // ...

}
Enter fullscreen mode Exit fullscreen mode
// after
def improved(x: String, y: Int): Boolean = {

  val intermediateValueA = a(x)
  val intermediateValueB = b(intermediateValueA, y)
  val intermediateValueC = c(intermediateValueB)
  // ...
  val intermediateValueH = h(intermediateValueG)
  val intermediateValueI = i(intermediateValueH)

  j(intermediateValueI)

}

private def a(x: String): Long = {
  // ...
}

private def b(z: Long, y: Int): Double = {
  // ...
}

// eight more private functions...
Enter fullscreen mode Exit fullscreen mode

所有这些方法都可以private(或者任何与你的语言等效的方法)。在这种情况下,它们只能由包含它们的类访问。它们以前都是在一个方法中,所以我们可以确保没有其他人在其他地方使用这个逻辑。

但现在我们面临另一个抉择:是否应该为所有这些单独的方法编写单元测试?对于我们中的许多人来说,我们的直觉反应是“是”。然而,这可能会使未来的重构更加困难,因为“测试越接近实现,它们对变化就越敏感”

有趣的是,我曾经处理过包含数百个类似测试的代码库,它们都与生产环境的实现紧密耦合。给一个类添加一个字段意味着需要更新一百多个测试,这些测试根本不关心这个字段,但需要这个新字段才能编译通过。测试变更的实现时间通常比生产环境变更更长。

为这些较小的方法编写单元测试可能还需要我们使它们变得public比需要的更多;这个类之外的世界并不关心这些单独的方法,它所关心的只是improved现在将它们联系在一起的一种方法。

真正的问题是,这些函数是代码的“单元”吗?

答案是否定的。

正如肯特·贝克 (Kent Beck) 所说,这些不是“外部可见系统行为的入口点”。

上面重构的示例中,唯一外部可见的入口点是improved函数,就像original最初的函数一样。但是这些普遍的想法……

  1. 大型函数应该分解成较小的函数,并且
  2. “单元测试”是“单一功能的测试”

...结合起来产生的结果比其各部分的总和要糟糕得多:大量的测试套件与不必要的公开生产代码紧密耦合,编写时间过长且难以维护。

这样的结果让许多开发人员相信这样的事情......

“大多数单元测试都是浪费”

——James O. Coplien

测试种类

我认为,与其按照传统的单元/集成/端到端的频谱来思考测试,不如从其他几个维度来思考。

  1. 这个测试是还是
  2. 这是黑盒测试还是白盒测试
  3. 这个测试是开发来通知的还是它通知开发?

快速测试和慢速测试

首先我要声明的是,在这种情况下,“快”并不等同于“好”,而“慢”也不等同于“坏”。

快速测试是指在几秒、几毫秒、几微秒甚至更短时间内运行的测试。因此,快速测试必须完全在内存中运行。它们不进行磁盘 IO 操作,也不进行网络调用。它们可以在每次代码更改时运行,而不会影响开发速度,因此应该作为开发人员内部循环的一部分运行。每次编译时,都可以运行这些测试。

慢速测试需要花费几秒钟、几分钟或几小时才能运行。快速测试和慢速测试之间的分界线大约在 2-5 秒之间。慢速测试可能需要从磁盘读取大型输入文件、进行大量计算或通过网络进行通信。也就是说:它们受IO、CPU 或网络限制契约测试(通常启动 Docker 容器)和性能测试(可能通过系统运行 GB 的数据或数千个请求)就是慢速测试的例子。这些测试应该不那么频繁地运行,因为它们会影响开发速度:对于短于几分钟的测试,每次提交之前main可能master没问题,而对于长于几分钟的测试,每天或每周可能是一个很好的节奏。

黑盒测试和白盒测试

黑盒测试不会对被测对象的内部结构做出任何假设。它们提供输入,并对可观察的输出进行断言,仅此而已。可观察的输出通常是方法的返回值,但黑盒测试可能会断言发生了副作用,例如写入了一行日志、记录了某个指标,或者某个状态发生了改变。

白盒测试专门测试被测对象的内部结构。它们是内省式的。带有诸如“当‘x’发生时,函数 a() 应该调用函数 b()”之类断言的测试就是白盒测试。它们明确地测试某件事应该如何发生(某些代码是如何实现的),而不是仅仅测试它是否发生。严重依赖模拟框架的测试通常也是白盒测试,断言某个方法在响应某些输入时已被调用(或未被调用)。

如果你不关心某个东西是如何实现的,只关心它是否按预期完成,那么你应该编写黑盒测试。通常情况下,黑盒测试是默认的。

发展知情测试和发展信息测试

开发导向测试是被动编写的,即先编写生产代码,然后再编写测试。开发导向测试会按原样规范系统的行为。传统的“单元测试”几乎完全是开发导向测试。

开发指导测试是主动编写的,即先编写测试,然后再编写生产代码。测试驱动开发 (TDD)是一种软件开发方法,它鼓励编写开发指导测试,确保系统的所有行为始终在测试中得到充分体现。

开发信息测试还能确保某些棘手的逻辑已正确实现。例如,您可以编写一个正则表达式来解析美国电话号码,同时添加一些测试,以确保捕获以下问题:

  • 括号内的区号
  • 空格、无空格、连字符
  • 是否存在+1国家代码

仅仅通过观察正则表达式,很难确定它是否能捕捉到所有这些情况。通常情况下,编写一些简单的测试来确保最常见的边缘情况得到正确处理会更有说服力。

我也总是以开发指导的方式编写错误修复测试。首先,我会编写一个应该通过的测试,但由于存在错误,我预计它会失败。然后,我会在生产代码中修复该错误,确保测试现在能够通过。这个过程表明——如果测试最初就存在——它就能捕获到这个错误。这让我们确信该错误将来不会再次出现。

“大多数单元测试都是浪费”

上面概述的三种测试方式可以让我们了解为什么像James O. Coplien这样的开发人员认为大多数单元测试都是浪费时间。

大多数单元测试都是开发相关的

根据我的经验,大多数开发人员并不实践 TDD

因此,大多数测试都是基于开发的。开发人员编写一些生产代码,然后编写测试,通常是为了确保达到一定的代码覆盖率。

这些测试并不是为了捕捉错误而编写的,也不是为了帮助开发人员思考一些困难的实现而编写的,因此它们的价值并不是立即显现出来的。

大多数单元测试不测试“外部可见的系统行为”

如前所述,(1) 将大型函数拆分成多个小函数;(2) 为每个函数(而不是每个外部可见的系统行为)编写测试,这两种做法会导致大量与生产实现紧密耦合的测试。这些测试本质上很脆弱。即使外部可见的系统行为完全相同,只要实现细节发生哪怕是最小的变化,也必须更新它们。

这经常发生在使用模拟框架时,因为必须声明模拟对象上调用的每个方法,并指定其返回值。

在最糟糕的情况下,开发人员有时会直接将生产代码的实现复制粘贴到测试中,并声称测试代码的“预期”结果等于生产代码的“实际”结果。这种白盒测试即使能提高“代码覆盖率”,也毫无疑问没有任何价值。

新的测试金字塔

传统的测试金字塔旨在向开发人员强调,他们应该主要编写“单元测试”,较少的“集成测试”和少量的“端到端测试”。尽管金字塔的不同表述可能对后两个层级使用不同的术语,但几乎所有人都同意金字塔的底层应该由“单元测试”组成。谷歌建议将单元测试、集成测试和端到端测试的比例分别设定为 70%、20% 和 10%。

其理念是,你应该用小型、快速的测试来覆盖大部分代码逻辑,这些测试可以在开发的内循环中反复运行。你的集成测试应该涵盖单元之间的交互;你的端到端测试应该验证最终用户的操作是否会导致预期的总体结果。

这个建议没问题,只要所有开发人员都同意什么是“单元测试”或“集成测试”。显然,情况并非如此。(请参阅本博文顶部的搜索引擎结果。)然而,我们可以使用上述客观标准(快速 vs. 慢速、黑盒 vs. 白盒、开发知情 vs. 开发知情)来构建一个新的测试金字塔。

新的测试金字塔

基地

尽可能选择黑盒测试。如果需要外部依赖,最好使用模拟实现而不是模拟main(并添加相应的契约测试,以确保外部依赖的行为符合您的预期)。这样可以将整个测试保存在内存中,使其足够快,可以在每次提交到/之前运行master。您会发现大多数测试都是这种快速的黑盒测试。

请注意,这与“单元测试”不同。如上所述,传统的“单元测试”通常速度很快,但有时是白盒测试,并且通常需要开发人员的指导。

中间

优先选择开发导向型测试,而非开发导向型测试(优先选择 TDD 开发方式)。开发导向型测试通常是死记硬背,价值不大。

相比快速白盒测试,更倾向于慢速黑盒测试。前者与生产实现的耦合度较低,因此更易于维护。

传统的“集成测试”和“端到端”测试都属于“缓慢、黑盒测试”类别。

顶部

尽可能少写白盒测试。也就是说,尽量少做代码自检。只测试可观察的输出。

仅在必要时编写开发知情测试。如果生产实现有效,那么它就有效。如果无效,你需要找到一个错误,编写一个开发知情测试,并修复该错误。这个过程如上所述。

结论

传统的单元/集成/端到端测试分类非常模糊。对“单元测试”的定义存在分歧,再加上一些出于好意却被误用的建议,例如通过减少每个函数、类等的代码行数来提高代码可读性,导致难以维护、价值低下的测试套件泛滥,对开发人员的生产力产生了负面影响。

使用上面描述的三个标准客观地对测试进行分类,可以产生更易于维护的测试,从而提供更多价值。

鏂囩珷鏉ユ簮锛�https://dev.to/awwsmm/there-is-no-such-thing-as-a-unit-test-50j3
PREV
JavaScript 中 5 个有用的数组方法
NEXT
超级简单的 Markdown 常见格式标题超级秘密的 Markdown 技巧(他们*不想让你知道!)