Thinking about tests: assertions and matchers Ruby developer Andrei Kaleshka argues that writing short, readable, and expressive test code remains essential even in the age of AI agents, and explores how the design of assertion and matcher APIs shapes developer thinking and code maintainability. He traces the evolution from manual code checking to codified test assertions, emphasizing that the lowest level of a test—a single assertion—affects the entire testing experience. Thinking about tests: assertions and matchers /blog/2026-08-16-assertions-and-matchers.html 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. 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 morein this age. I extend this argument a bit in the last section of the post. 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. 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. 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.” In testing, it might be used like this usual “arrange, act, assert” structure 1: 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.” 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 result == expected value, 'Explanation of the case tested' …and adding the explanation to the failure message. In fact, the first JUnit library 2 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 assertion fails, “it was not equal” is not enough useful information; “but what it was ” would be the immediate follow-up question. So the logical next step is to have a small utility wrapper: assert equal result, expected value …which compares two values and, if they aren’t equal, renders something informative, like "expected: 1, was: 2" . A pedantic note: In JUnit, the declared order is actually assert equal expected, actual . Modern JUnit and some of the JUnit-derived libraries preserve this order; others switch to actual, expected ; still others refuse to confine the developer and use neutral naming like left, right . Finally, there are those that, like LuaUnit, make it a configuration option . While this might seem a “boring nuance,” we show that this order decision matters further in the article. Once we have this helper function, one might think of other APIs in the same line of reasoning: assert that value is that of the expected type and properly render what actual type it was otherwise ; assert that it is a collection and has an expected number of items; assert that it includes some specific structural subpart, and so on. These bunches of assert something APIs are still, for all I can tell, the most popular testing API. All across the programming languages spectrum, the “xUnit-style” testing library is frequently the default/most used one, if not in newer “batteries included” languages part of the core distribution itself. To work on this article and, hopefully, the next parts , I made a private quick comparison document of many test libraries throughout the mentioned “spectrum” of modern programming languages. I am thinking about publishing it as a separate post/document, as it turns out to be of interest for any living soul other than me. Still, another style exists, and it is almost equally widespread. And, as far as I am able to research, its popularity if not the style itself had originated from Ruby. “Behavior-driven development” and the invention of a matcher Around 2005-2007, there were a lot of blog posts here https://dannorth.net/blog/introducing-bdd/ is one of the definitive ones discussing the ideas of “behavior-driven development” – mostly, a new way of thinking, or rather a shift from the familiar ways. While the initial approach to test-driven development made the code author think in terms of “how can I write the test for my non-existent yet code, that will help me design it,” behavior-driven development suggests thinking in terms of “how can I describe the desired behavior of non-existent yet code.” The difference might sometimes seem subtle, though it was believed that this shift of perspective might mean a lot. One of the influential articles demonstrates how subtle the shift might seem: 2005’s A new look at test-driven development https://web.archive.org/web/20090419153557/http://techblog.daveastels.com/2005/07/05/a-new-look-at-test-driven-development/ . Dave Astels makes a big distinction between “old” test-driven development with assertEquals expected, actual and “behavior-driven” shouldBeEqual actual, expected . From today’s point of view, these two APIs might seem effectively indistinguishable. A lot of software developers today would frown at almost any syntax/API discussion as not making a difference for a “professional engineer.” But in those old times people considered that choice of singular “words” and the shape of a “phrase” in the programming language affects the writer’s thinking. I still believe this, and that this belief is one of the things that make me efficient. In the article, Dave Astels also says that in Smalltalk, and possibly Ruby, it might be even more natural with Smalltalk’s syntax : actual shouldEqual: expected or result shouldBeNull or 2 / 0 shouldThrow: DivideByZeroException . Soon the Ruby’s RSpec library was born https://blog.davidchelimsky.net/blog/2007/05/14/an-introduction-to-rspec-part-i/ , directly inspired by Dave’s article and with significant contributions of Dave himself. The first version provided almost exactly the same syntax Dave had described: actual.should equal expected It went from several hard-coded should