Thinking about tests: assertions and matchers
Thinking about tests: assertions and matchers
关于测试的思考:断言与匹配器
Why some of us are still bothered about the way we write tests and what the “matcher” concept has to do with it. During most of my Ruby career, I was that unpleasant person who honestly enjoys writing tests and is frequently concerned about the ways we write them. This means treating unit tests like the rest of the codebase: like something that is supposed to be read by humans and something that should be written efficiently and expressively. Basically, like something that wouldn’t be boring and disgusting to read and write. 为什么我们当中有些人仍然纠结于编写测试的方式,以及“匹配器”(matcher)概念与此有何关联?在我大部分的 Ruby 职业生涯中,我就是那个令人讨厌的人,我真心喜欢编写测试,并且经常关注我们编写测试的方式。这意味着要像对待代码库的其他部分一样对待单元测试:它们应该是供人类阅读的,并且应该高效且富有表现力地编写。简而言之,它们不应该是那种读起来或写起来令人感到枯燥和厌恶的东西。
This also means that I find it useful, once in a while, to stop and reflect on why we write test code the way we write it. And can this be improved? Let’s move the elephant in the room from our way at once: from my point of view, this way of thinking does not become obsolete due to AI agents, who “can write any number of tests without being bored.” If anything, short, readable, and expressive code means more in this age. I extend this argument a bit in the last section of the post. 这也意味着,我发现偶尔停下来反思我们为何以当前方式编写测试代码是有益的。这能改进吗?让我们先解决那个显而易见的问题:在我看来,这种思维方式并不会因为 AI 代理的出现而过时,尽管 AI “可以不知疲倦地编写任意数量的测试”。恰恰相反,在当今时代,简洁、可读且富有表现力的代码意义更加重大。我将在本文的最后一部分对这一观点进行一些扩展。
So, thinking about “how do we write tests” leads to a mass of related, tightly intertwined questions: How does the typical test in the codebase look? How hard is it to write a new one? How hard is it to read and maintain an existing one? How does it affect the overall codebase maintainability? And how the design of the test framework and its utilities affects all these considerations and is affected by them? To understand how this way of thinking might be useful, let’s look at the lowest level of the test: just one check, or assertion. 因此,思考“我们如何编写测试”会引出一系列相关且紧密交织的问题:代码库中典型的测试是什么样的?编写一个新的测试有多难?阅读和维护现有的测试有多难?它如何影响整个代码库的可维护性?测试框架及其工具的设计如何影响所有这些考量,又如何受到它们的影响?为了理解这种思维方式为何有用,让我们看看测试的最底层:仅仅是一个检查,或者说“断言”(assertion)。
Starting from the beginning
从头开始
Let’s perform a small “from the first principles” journey (bear with me!). How do we check that the code we just wrote does what expected of it? It starts easy when you have just a small amount of new code to test: one script, one utility function, one small class, things like that. The first, most naive approach, is to just run the code, see what it outputs (prints to the console, renders in browser, or makes any other user-visible effect), and compare it visually with what you’d like it to output. 让我们进行一次小小的“第一性原理”之旅(请耐心听我说!)。我们如何检查刚写的代码是否按预期运行?当你只有少量新代码需要测试时,这很容易:一个脚本、一个工具函数、一个小类等等。第一种最天真的方法是直接运行代码,查看其输出(打印到控制台、在浏览器中渲染,或产生任何其他用户可见的效果),并将其与你期望的输出进行视觉对比。
Frequently, this is enough for a quick prototype or a throw-away script: just write the code, run it, say “aha” or “oh no,” tinker a bit till you are happy, and then move over. Obviously, it becomes tiresome for any non-trivial code, or one that turned out to be not short-lived: you eventually need it to do more and more things under more and more circumstances, and just manually checking “my new case is working, and the old one is not broken” becomes a burden. 通常,对于快速原型或一次性脚本来说,这就足够了:写代码、运行、说声“啊哈”或“糟糕”、稍微调整直到满意,然后继续。显然,对于任何非平凡的代码,或者那些并非短命的代码来说,这种方式会变得令人厌烦:你最终需要它在越来越多的情况下做越来越多的事情,而仅仅手动检查“我的新用例在工作,旧用例没坏”会成为一种负担。
And so you need some kind of a “test script,” with “when we run it like this, that happens” codified. This “check what happens” should be easy to write, and it should provide useful feedback: “in this part of the test script, this assumption turned out to be incorrect.” This is what we frequently call “test assertion”, “test check,” or “test expectation,” depending on the context and tools used. The API to assert things is one of the first services that any test tool provides. And one that, in my opinion, affects the test library usage and developer’s thinking process. 因此,你需要某种“测试脚本”,将“当我们这样运行时,会发生那样的事”编码化。这种“检查发生了什么”的过程应该易于编写,并且应该提供有用的反馈:“在测试脚本的这一部分,这个假设被证明是不正确的。”根据上下文和所使用的工具,我们通常将其称为“测试断言”、“测试检查”或“测试期望”。断言的 API 是任何测试工具提供的首要服务之一。在我看来,它也影响着测试库的使用和开发者的思维过程。
Of course, we can go to higher levels to think how we organize many tests and groups of tests. And also how do we run them – a lot of decisions can be made here: order of tests, their independence, running in parallel, rerunning only a subset. All of this unquestionably affects our thinking, the design of our tests, and the design of our software. But it all starts with one test – and one assertion. 当然,我们可以上升到更高的层面,思考如何组织大量的测试和测试组。以及我们如何运行它们——这里可以做出许多决策:测试的顺序、它们的独立性、并行运行、仅重新运行子集等。所有这些无疑都会影响我们的思维、测试的设计以及软件的设计。但这一切都始于一个测试——以及一个断言。
Not everyone considers “how do we write one test” to be of any importance. In the “architecture-first” thinking, the particular code at the level of singular “paragraphs” and “phrases” – its brevity, expressiveness, or ease of modification – is frequently brushed off as insignificant. My way of thinking on ease of development and maintenance of software, though, gives this “low” level significance. I will follow this line of thinking for now without further argument (which I expressed many times already). And I ask you to be with me here, if only out of curiosity, “how some of us approach what they do.” 并非每个人都认为“我们如何编写一个测试”很重要。在“架构优先”的思维中,处于单个“段落”和“短语”层面的具体代码——其简洁性、表现力或易修改性——经常被视为无关紧要而被忽略。然而,我关于软件开发和维护简易性的思维方式,赋予了这种“底层”以重要性。我目前将遵循这一思路,不再赘述(我已经表达过很多次了)。我请求你在这里跟随我,哪怕仅仅是出于好奇,看看“我们当中有些人是如何对待他们所做的工作的”。
So: a single assertion
那么:单个断言
The simplest of such APIs is assert(expression), with expression expected to return either truth/truthy value (the test passed) or false/falsy value (the test failed). Frequently, this assert is even a part of the language itself, or its standard library – to be used as a debug or production guard against “impossible conditions.”
这类 API 中最简单的是 assert(expression),其中 expression 预期返回真值(测试通过)或假值(测试失败)。通常,这个 assert 甚至是语言本身或其标准库的一部分——用作调试或生产环境中的防护,以防止出现“不可能的条件”。
In testing, it might be used like this (usual “arrange, act, assert” structure): 在测试中,它可能像这样使用(通常的“准备、执行、断言”结构):
arguments = prepare_arguments() # arrange
result = execute_code(arguments) # act
assert(result == expected_value) # assert
Here, only the last line has any calls that should be provided by a test library. Or, if it is a “core language” assertion feature, the only role of the test framework here is to provide a hook/handler for the signal that failed assertion produces (by raising an exception or other means). Throw in some API or agreement how you put such fragments in separate tests and how are they executed (the common approach: every method/function in tests/ folder files that is named test_something is run separately) – and this is already enough for the smallest, yet useful, “testing library.”
在这里,只有最后一行包含需要由测试库提供的调用。或者,如果它是“核心语言”的断言特性,测试框架在这里的唯一作用就是为断言失败产生的信号提供钩子/处理器(通过抛出异常或其他方式)。加上一些 API 或约定,规定如何将这些片段放入单独的测试中以及如何执行它们(常见做法:tests/ 文件夹中所有以 test_something 命名的函数/方法都会被单独运行)——这就足以构成一个最小但有用的“测试库”了。
If not provided by the language itself, such an assert can be trivially implemented as a method that just throws an AssertionError exception if the passed argument is falsy. The exception’s backtrace will point to the failed line, giving enough basic information to debug. To make it a bit more friendly, a message argument can be added to the assert signature, allowing the developer to write:
如果语言本身没有提供,这样的 assert 可以简单地实现为一个方法:如果传入的参数为假,则抛出 AssertionError 异常。异常的回溯信息将指向失败的行,提供足够的调试基础信息。为了使其更友好,可以在 assert 签名中添加一个 message 参数,允许开发者这样写:
assert(result == expected_value, 'Explanation of the case tested')
…and adding the explanation to the failure message. In fact, the first JUnit library was not much more than this. But still, there was some more. Even in the most basic case – the “result should be equal to the expected outcome” – if the asser… ……并将解释添加到失败消息中。事实上,最初的 JUnit 库也不过如此。但即便如此,还是有一些不足之处。即使在最基本的情况下——即“结果应该等于预期结果”——如果断言……