思考测试:断言与匹配器
摘要
本文探讨了软件开发中测试断言与匹配器的设计,强调了它们在编写可读且高效的测试代码中的作用,特别是在 Ruby 中。
<p><a href="https://lobste.rs/s/usr2t2/thinking_about_tests_assertions">评论</a></p>
查看缓存全文
缓存时间: 2026/08/17 12:06
# 思考测试:断言与匹配器
来源:https://zverok.space/blog/2026-08-16-assertions-and-matchers.html
**为什么我们中有些人仍然在意测试的编写方式,以及"匹配器"概念与此有何关联。**
在我的Ruby职业生涯中,大部分时间里,我都是那种令人讨厌的人——真心乐于编写测试,并时常关注我们编写测试的方式。这意味着将单元测试视为代码库的其余部分:应当**由人类阅读**,并且应该**高效且富有表现力**地编写。本质上,就是那些阅读和编写起来不会令人感到无聊和厌烦的东西。这也意味着,我觉得偶尔停下来反思*为什么*我们要以当前的方式编写测试代码,并思考是否可以改进,是非常有益的。
> 让我们先把房间里的大象(显而易见的问题)挪开:在我看来,这种思维方式不会因为能够"不知疲倦地编写任意数量测试"的AI代理而过时。如果有什么变化的话,在这个时代,简短、可读且富有表现力的代码意味着*更多*。我在文章的最后一部分稍微扩展了这个论点。
> 因此,思考"我们如何编写测试"会引出一系列相关且紧密交织的问题:代码库中的典型测试是什么样的?编写一个新测试有多困难?阅读和维护现有测试有多困难?这如何影响整个代码库的可维护性?以及测试框架及其工具的设计如何影响所有这些考量,又如何被这些考量所影响?
> 为了理解这种思维方式可能如何有用,让我们从测试的最低层次开始:**仅仅一次检查,或者说一次断言。**
## 从头开始
让我们进行一次小型的"第一性原理"之旅(请耐心跟随!)。我们如何检查刚刚编写的代码是否达到了预期?
当你只有少量新代码需要测试时——比如一个脚本、一个工具函数、一个小类——事情开始时很简单。第一个、最天真的方法就是直接运行代码,查看它的输出(打印到控制台、在浏览器中渲染或产生任何其他用户可见的效果),然后将其与你期望的输出进行视觉比对。
对于快速原型或一次性脚本,这通常就足够了:编写代码,运行它,说一声"啊哈"或"哦不",稍微调整一下直到满意,然后继续前进。
显然,对于任何非平凡的代码,或者最终不是短暂存在的代码,这种方法会变得非常繁琐:你最终需要它在更多情况下完成越来越多的功能,而仅仅手动检查"我的新用例在工作,旧用例没有被破坏"就变成了一种负担。
因此,你需要某种"测试脚本",将"当我们这样运行它时,会发生那样的事情"编码化。
这个"检查会发生什么"应该易于编写,并且应该提供有用的反馈:"在这个测试脚本的这部分,这个假设被证明是不正确的。"
这就是我们通常根据上下文和使用的工具所称的"测试断言"、"测试检查"或"测试期望"。
**断言事物的API**是任何测试工具提供的首要服务之一。而且,在我看来,它影响着测试库的使用和开发者的思考过程。
> 当然,我们可以从更高的层次来思考如何组织许多测试和测试组。以及我们如何运行它们——这里有很多决策:测试顺序、它们的独立性、并行运行、只重新运行其中一部分子集。所有这些无疑会影响我们的思维、测试的设计以及软件的设计。但一切都始于一个测试——以及一个断言。
> 并非所有人都认为"我们如何编写一个测试"有任何重要性。在"架构优先"的思维中,单个"段落"和"短语"层面的具体代码——其简洁性、表现力或易修改性——通常被视为无关紧要而忽略不计。
> 然而,我关于软件易开发性和可维护性的思维方式赋予了这个"低"层次重要性。我将沿着这条思路走下去,暂不进一步论证(我[已经表达过](https://zverok.space/blog/2023-10-02-syntax-sugar.html#how-i-think-about-the-programming-language)很多次了)。我请求你们在这里跟随我,即使只是出于好奇,想看看"我们中有些人是如何处理他们的工作的"。
## 那么:单个断言
这种API最简单的形式是`assert(表达式)`,其中`表达式`期望返回真值/truthy值(测试通过)或假值/falsy值(测试失败)。通常,这个`assert`甚至是语言本身或其标准库的一部分——用作调试或生产中针对"不可能条件"的防护。
在测试中,它可能像这样使用(通常的"准备,执行,断言"结构)[^1]:
```ruby
arguments = prepare_arguments() # 准备
result = execute_code(arguments) # 执行
assert(result == expected_value) # 断言
```
在这里,只有最后一行有需要由测试库提供的调用。或者,如果它是"核心语言"的断言特性,测试框架在这里唯一的作用就是为失败断言产生的信号(通过抛出异常或其他方式)提供一个钩子/处理器。
再添加一些关于如何将这些片段放入独立测试中以及如何执行它们的API或约定(常见方法:`tests/`文件夹中名为`test_something`的文件中的每个方法/函数都单独运行)——这已经足够构成一个最小但有用的"测试库"了。
如果语言本身没有提供,这样一个`assert`可以简单地实现为一个方法,如果传递的参数为假值,则抛出一个`AssertionError`异常。异常的回溯信息将指向失败的行,提供足够的基础信息进行调试。
为了使其更友好一些,可以在`assert`签名中添加一个消息参数,允许开发者编写:
```ruby
assert(result == expected_value, '被测试用例的解释')
```
...并将该解释添加到失败消息中。
事实上,第一个JUnit库[^2]并不比这复杂多少。
但尽管如此,它*确实*多了一些东西。即使在最基本的情况下——"结果应该等于预期结果"——如果断言失败,"不相等"并不是足够有用的信息;"但它*实际*是什么?"将是紧随其后的问题。
因此,逻辑上的下一步是拥有一个小的实用包装器:
```ruby
assert_equal(result, expected_value)
```
...它比较两个值,如果不相等,则呈现一些信息性内容,如`"expected: 1, was: 2"`。
> 一个迂腐的注释:在JUnit中,声明的顺序实际上是`assert_equal(expected, actual)`。现代JUnit和一些JUnit派生的库保留了这个顺序;其他库切换到了`(actual, expected)`;还有一些拒绝限制开发者,使用中性命名如`(left, right)`。最后,还有像LuaUnit这样的库,使其成为[一个配置选项](https://luaunit.readthedocs.io/en/latest/4_reference_doc.html#equality-assertions)。虽然这看起来像是一个"无聊的细微差别",但我们将在后文中展示这个顺序决定的重要性。
一旦我们有了这个辅助函数,人们可能会沿着同样的思路考虑其他API:断言值具有预期的类型(否则实际渲染它是什么类型);断言它是一个集合并具有预期数量的项目;断言它包含某个特定的结构子部分,等等。就我所知,这些`assert_something`系列API仍然是最流行的测试API。在所有编程语言中,"xUnit风格"的测试库通常是默认/最常用的,如果不是(在新的"自带电池"语言中)作为核心分发的一部分的话。
> 为了撰写本文(以及希望中的后续部分),我制作了一份私人快速比较文档,涵盖了上述"光谱"中的众多现代编程语言测试库。我正在考虑将其作为单独的帖子/文档发布,因为事实证明,除了我之外,它对任何活人都有吸引力。
然而,还存在另一种风格,并且它几乎同样普遍。而且,就我所能研究的,它的流行(如果不是风格本身的话)可能起源于Ruby。
## "行为驱动开发"与匹配器的发明
大约在2005-2007年,有很多博客文章([这里](https://dannorth.net/blog/introducing-bdd/)是其中一篇经典)讨论"行为驱动开发"的理念——主要是一种新的思维方式,或者说是从熟悉方式的转变。
虽然最初测试驱动开发的方法让代码作者思考"我如何为*我尚不存在的代码*编写*测试*,这将帮助我设计它",但行为驱动开发建议以"我如何*描述尚不存在的代码的预期行为*"来思考。
这种差异有时可能看起来很微妙,但人们相信这种视角的转变可能意义重大。
其中一篇有影响力的文章展示了这种转变可能看起来多么微妙:2005年的[重新审视测试驱动开发](https://web.archive.org/web/20090419153557/http://techblog.daveastels.com/2005/07/05/a-new-look-at-test-driven-development/)。Dave Astels 在`assertEquals(expected, actual)`的"旧"测试驱动开发和"行为驱动的"`shouldBeEqual(actual, expected)`之间做出了重大区分。
> 从今天的角度来看,这两个API可能看起来实际上无法区分。今天许多软件开发者会对几乎任何语法/API讨论皱眉,认为这对"专业工程师"没有区别。但在那个古老的时代,人们认为编程语言中单个"词"的选择和"短语"的形状会影响作者的思维。我仍然相信这一点,并且这种信念是让我高效的原因之一。
在文章中,Dave Astels还说,在Smalltalk中,也可能在Ruby中,使用Smalltalk的语法甚至可能更自然:
> `actual shouldEqual: expected` 或 `result shouldBeNull` 或 `[2 / 0] shouldThrow: DivideByZeroException`。
不久,Ruby的RSpec库诞生了,它直接受到了Dave文章的启发,并且Dave本人也做出了重大贡献。第一个版本几乎完全提供了Dave所描述的语法:
```ruby
actual.should_equal expected
```
它从0.0.1版本中几个硬编码的`should_`方法(如`should_equal`)开始,经历了0.0.4版本中像`should.do.something`这样的元编程语法变体,然后是`should_do_something`(但现在是通过元编程解析成单词),并在0.8.0版本(2007年2月)中引入了这个[^3]:
```ruby
actual.should eq(expected)
```
> Ruby的灵活语法允许在没有歧义的情况下省略方法调用括号。上面的代码等同于
> ```ruby
> actual.should( eq(expected) )
> ```
> ...所以这里的`should`实际上是一个期望一个参数的方法——一个**匹配器**。
但它也允许省略更多括号,并将相同的代码写成这样,使得`eq`看起来像是actual和expected之间的操作符:
```ruby
actual.should eq expected
```
含义保持不变:使用`eq(expected)`创建的**匹配器对象**与`.should`是分开的实体。
那是Ruby的灵活语法及其允许构想的API启发其他社区尝试在其语言中实现类似功能的时代。这发生在Rails及其各种API上,也发生在RSpec上。在某些语言中,这个想法或多或少被直接移植,而在其他语言中,它要求仅仅为了模仿`actual should matcher`公式而拉伸语法技巧。
> 快速举例,Python的should-dsl(https://github.com/nsi-iff/should-dsl)通过定义一个特殊的`should`对象,以`actual |should| expected`的方式提供,在该对象上定义了`|`运算符,使得整个语句产生一个期望。我不认为它被广泛使用。
Ruby的类在设计上是开放的(你可以在任何程序中继续定义任何类——甚至是系统类)。在那个时代的Ruby社区,"将需要的东西直接扔进核心类"是一种流行的开发方法。所以`.should`方法最初只是被添加到RSpec版本中的每个`Object`上。
最终,"为了编写稍微好一点的代码而扩展每个对象"在Ruby社区中不再流行,在几个版本中RSpec[确定](https://rspec.info/blog/2012/06/rspecs-new-expectation-syntax/)使用"包装对象"API,它不需要无条件地扩展基础`Object`:
```ruby
expect(actual).to matcher
```
这里,`expect(actual)`创建一个包装对象,该对象具有像`.to(matcher)` / `.to_not(matcher)`这样的方法,这几乎和`actual.should`一样紧凑,但不会用测试库的方法污染每一个存在的对象。
## 匹配器的广泛应用
类似于这个"新"API的API,或者RSpec最初的`.should`语法,如今可以在许多语言和测试库中找到。例如,Go的Ginkko(https://onsi.github.io/ginkgo/)使用`Expect(actual).To(Equal(expected))`,这正是RSpec的API,只是标点符号更严格[^4]。
Scala和Kotlin都享有"中缀函数"表示法,其标点灵活性甚至比Ruby允许的还大:ScalaTest(https://www.scalatest.org/)使用`actual should equal (expected)`,而KoTest(https://kotest.io/docs/assertions/matchers.html)使用`actual should eq expected`。
在JavaScript中,两个著名的库——Jest(https://jestjs.io/docs/expect)和Chai(https://www.chaijs.com/guide/styles/#expect)——都提供了`expect`表示法,但有一个转折。在一个有点奇怪的转折中,它们都将自己的解决方案称为"匹配器",但并没有提供独立的"匹配器对象":在Chai中是`expect(actual).to.something(expected)`,在Jest中是`expect(actual).toSomething(expected)`。虽然在视觉上类似于其他RSpec类库,但这种方法有一个显著的不同:这里的"匹配器"不是单独构造的参数,而是`expect`产生的包装对象的方法。因此,创建自定义匹配器(我们稍后会讨论)需要扩展该对象,而不仅仅是创建符合匹配器接口的独立对象。
Lua的Lust(https://github.com/bjornbytes/lust)也走了同样的道路。
**值得注意的是**,使用匹配器的概念并不一定需要带有"should"/"expect to"的整个公式。事实上,就我通过开发者思想考古所能挖掘到的,匹配器的概念可能最早出现在Java的Hamcrest(https://hamcrest.org/JavaHamcrest/)库中——它甚至一度成为JUnit 4的一部分(但后来又被分离出来),然后[被移植到许多其他语言](https://hamcrest.org/)。Hamcrest只是将匹配器的概念引入到熟悉的`assert` API中,使用`assertThat`:
```java
assertThat(actual, equalTo(expected))
```
其他几个库遵循这种或类似的结构,例如:
- C++的GoogleTest(https://google.github.io/googletest/)使用`EXPECT_THAT(actual, Eq(expected))`;
- 它的Rust移植版(https://crates.io/crates/googletest)使用`expect_that!(actual, eq(expected));`;
- C++的Catch2(https://catch2.org/)使用`REQUIRE_THAT("Hello world", StartsWith("Hello") && EndsWith("world"))`;
- C#的NUnit(https://docs.nunit.org/)使用`Assert.That(phrase, Does`
相似文章
介绍 Test That
Test That 是一个新的 Rust 测试断言库,源自 GoogleTest Rust,提供更简洁的 API、可选的依赖项以及更好的匹配器组合性。
我为SPA编写自动化测试的经验
作者分享了他们使用Playwright为单页应用构建自动化测试套件的经验,强调了端到端测试、数据隔离和模拟外部服务。
上下文使测试更具可重用性
作者分享了在Guile中设计测试框架的经验,重点探讨了向测试定义添加上下文如何使测试更可重用并改善开发者体验。
你必须修复你的断言
本文认为在生产环境中禁用断言是一种不好的实践,以Zig的断言机制为例,说明即使在生产版本中也应保持断言启用以捕获编程错误的好处。
可组合测试
这篇文章讨论了软件测试中的可组合测试,解释了如何通过组合测试来提高可读性、可维护性和效率,同时保持隔离性和特异性等属性。