DEV Community

Cover image for What a Failure Message Owes Its Reader
Tarek
Tarek

Posted on

What a Failure Message Owes Its Reader

A test fails at 2 AM. You open the CI log, and the entire story of the failure is one line:

Expected True, but found False.
Enter fullscreen mode Exit fullscreen mode

Which True? In which call? The test holds nine assertions, and a line written this way rules none of them out.

The build agent that ran it is already gone. The branch may be deleted next week. But the line stays — pasted into a chat thread, quoted in a ticket, screenshotted into a postmortem. A failure message is the only part of your test suite guaranteed to be read outside the editor, by people who never saw the code. That is the audience this piece is written for.

This is the moment a test library stops being an API and becomes a document. We choose libraries by their fluent surface — Should(), .Be(), .And — but the artifact most of us actually read, under pressure, months later, without context, is the failure text. Call it what it is: the diagnostic surface of the test suite. Most of us have never asked what that surface owes its reader.

Try it in sixty seconds. The central claim of this piece is checkable, so check it:

dotnet add package Jeninnet.Assertions
Enter fullscreen mode Exit fullscreen mode
using Jeninnet.Assertions;

[TestMethod]
public void Username_matches_expected()
{
    string username = "dennis";
    username.Should().Be("jonas");
}
Enter fullscreen mode Exit fullscreen mode
Expected username to be 'jonas', but found 'dennis'.
Enter fullscreen mode Exit fullscreen mode

The package depends on no test framework, so the same three assertion lines work under MSTest, xUnit, or NUnit. Rename username to login and the message renames itself; run it on another machine and the message does not move.

This piece is the first of six notes on that surface — and a word on how to read them. These notes will not teach you the library's API; the book and the reference do that. They argue for the design behind it: why each surface looks the way it does, what it costs, and where it stops. Each note stands alone; together they describe one design.

The arc is fixed. After this contract: failure messages in Arabic — what changes when the reader under stress does not read English. Then time: how to test what happens later without sleeping through your own suite. Then memory: why allocation budgets belong inside unit tests. Then waiting: tests that poll for a truth that has not arrived yet instead of asserting a lie on a timer. Finally, validation that never throws — the same verbs, usable where exceptions are not allowed. Start here, with the contract.

Name the subject. A failure should say what failed, not only that something did. Compare:

Expected True, but found False.
Expected username to be 'jonas', but found 'dennis'.
Enter fullscreen mode Exit fullscreen mode

The second sentence survives being pasted into a chat thread with no code around it. The subject is named because the assertion captured the expression text at compile time — the compiler's CallerArgumentExpression attribute (C# 10) — not because someone hand-wrote a label that drifts from the variable within a sprint, and not because a stack walk consulted a PDB file that is not guaranteed to be deployed. There is a second-order effect worth stating plainly: rename the variable and the message renames itself. Hand-written labels rot; captured expressions cannot.

Without the subject, every failure begins the same way: open the code, count the assertions, bisect. Nine suspects, one line of evidence. The subject name collapses that search to zero — before you have opened anything, you already know which value, from which expression, failed which check.

State expected and actual, in that order. The reader is performing a comparison; hand them both operands in reading order. "Assertion failed" is a verdict with no case file.

Be stable. The same failure should produce the same text on your machine, a colleague's machine, and a build agent — across operating systems and UI cultures. Invariant formatting is not a cosmetic detail; it is what makes failure output searchable, comparable across runs, and safe to assert on in tests that test tests. This library's own suite — 1,242 tests, all green — asserts its messages as exact strings wherever they are stable, which is only possible because the messages were designed to be stable in the first place. Stability is a testable property, and it is tested.

Consider the alternative most suites live with: messages that change with the machine's UI culture, so the same failure reads one way on your laptop and another on the build agent. You cannot search history you cannot reproduce, and you cannot assert on text that moves. Invariance is what lets a failure message behave like data — diffable, greppable, quotable — instead of weather.

Scale down gracefully. A failure message is a summary, not a data dump. When the compared strings grow past two hundred characters or span lines, printing both in full buries the difference; the useful artifact is the difference itself — a unified diff with three lines of context, values truncated to a hundred characters. When a value is merely long, truncate it and let the reader reproduce the rest. The message answers one question: do you need to reproduce this, or do you already see it?

Point at the next move. Some failures are lookups that missed — a dictionary key, a JSON property, a line in a string collection. Listing what does exist, ordered by closeness and capped at three, turns a dead end into a next step:

Did you mean 'name'?
Enter fullscreen mode Exit fullscreen mode

Serve both readers. The human reads prose; the pipeline reads JSON. A library serious about the diagnostic surface treats these as two renderings of one structure: each collected failure carries its subject expression, its category, its message, and the actual and expected values where the verb reports them — serialized for machines, worded for people. Build the two surfaces separately and they drift; build them from one source and they stay honest.

In practice the machine rendering travels further than most engineers expect: attached to CI runs, stored beside dry-run reports, compared across nightly builds. A JSON report with the expression, the category, and both values is what makes a failure legible to tooling a year later, when nobody remembers the test.

Say why, when the reader asks why. A failure that describes only the state is half a diagnosis. The reader who attached because: "the connection dropped" gets one sentence of reasoning, not two unrelated facts. The because clause is appended, sentence-style, to whatever the verb already reported — the reason rides with the evidence instead of replacing it.

None of this replaces the debugger, and none of it matters if the test asserts nothing. The failure message is a last resort — which is precisely why it deserves first-class design. The last resort is where we all end up.

There is one more reader this contract has not served yet: the engineer whose working language is not English. Failure text is one of the rare surfaces whose reader is guaranteed to be under stress — and almost every library in this space speaks exactly one language, with anything else arriving as a translation. What the diagnostic surface owes that reader is the subject of the next piece.


Next: When the Failure Message Must Speak Arabic


About this series: These notes come from building Jeninnet.Assertions — a fluent, framework-agnostic assertion library, *MIT-licensed, with no test-framework dependency. This is article 1 of 6.

  • You can find the repository here: Jeninnet.Assertions(GitHub).
  • You can find the packages here:
    • Jeninnet.Assertions (NuGet).
    • Jeninnet.Assertions.Analyzer (NuGet).
    • Jeninnet.Assertions.MSTest (NuGet).

Top comments (0)