skip to content

Kotest's MatcherResult carries a passed flag plus two messages — failureMessage and negatedFailureMessage. Under what circumstances does each message reach the developer, and what breaks if you write them carelessly?

level: middleimportance: must knowfreq 40%

answer

  1. test() reports, never throws
  2. should → failureMessage; shouldNot → negatedFailureMessage
  3. negated message = the whole output of a shouldNot failure
  4. Kotest 5: () -> String lambdas, lazy; 4.x eager Strings
  5. ComparableMatcherResult adds actual/expected for diffs

basics

~20 s

failureMessage is shown when the matcher is used positively (should / a shouldX extension) and does not pass. negatedFailureMessage is shown when it is used negatively (shouldNot) and it does pass. Copying the positive text into the negated slot makes failures report the opposite of reality.

solid answer

~50 s

A Kotest matcher never throws — `Matcher<T>.test(value)` returns a `MatcherResult` with `passed()`, `failureMessage()` and `negatedFailureMessage()`. The assertion entry points decide which message escapes: `value should matcher` throws when `passed()` is false, using `failureMessage()`; `value shouldNot matcher` throws when `passed()` is **true**, using `negatedFailureMessage()`. So one matcher serves both directions, and the negated message is not decoration — it is the entire output of every `shouldNot` failure. In Kotest 5 the `MatcherResult` factory takes the two messages as `() -> String` lambdas, so they are only built when a failure is actually raised (Kotest 4 took eager `String`s). That makes expensive rendering — diffing a large collection, pretty-printing a domain object — free on the passing path. Good messages name the actual value and the expected property in both directions. The classic bug is pasting the positive wording into the negated slot, so `shouldNot` failures read as if the assertion had been positive.

code

kotlin · 10 lines
kotlin
fun beValidIsoDate(): Matcher<String> = Matcher { value ->
    MatcherResult(
        runCatching { LocalDate.parse(value) }.isSuccess,
        { "\"$value\" should be a valid ISO-8601 date" },
        { "\"$value\" should not be a valid ISO-8601 date" },
    )
}

"2026-08-18" should beValidIsoDate()
"not-a-date" shouldNot beValidIsoDate()

go deeper

for a junior

Recall the three parts of MatcherResult and that the second message is what a shouldNot failure prints.

for a middle

Explain the should/shouldNot routing precisely and show a matcher that words both polarities with the actual value included.

for a senior

Add the Kotest 5 lazy-lambda change, why matchers report instead of throwing, and how that keeps clue/collection behaviour intact.

for a principal

Frame it as an API contract: matchers are pure predicates plus rendering, which is what makes composition, inversion and failure routing possible at all — and set a team rule that both polarities are tested.

## What a Kotest matcher actually returns Kotest's assertion core is deliberately tiny. `Matcher<in T>` has one method: `test(value: T): MatcherResult`. A matcher **never throws** — it *reports*. That single design choice is what lets matchers be composed, inverted, adapted and collected, because a value can be examined without control flow leaving the function. `MatcherResult` carries three things: - `passed()` — did the rule hold for this value? - `failureMessage()` — the text to show when the rule was **expected to hold** and did not. - `negatedFailureMessage()` — the text to show when the rule was **expected not to hold** and it did. ## Two call paths select the message The infix entry points are what turn a result into a failure: - `value should matcher` — if `passed()` is `false`, raise an assertion failure carrying `failureMessage()`. - `value shouldNot matcher` — if `passed()` is `true`, raise an assertion failure carrying `negatedFailureMessage()`. Every `shouldXxx` / `shouldNotXxx` extension you write on top of a matcher ultimately delegates to one of those two. That is why a single matcher powers both directions of an assertion and why the negated message is mandatory work, not an optional nicety: it is the *only* output a `shouldNot` failure ever produces. A useful mental check while writing a matcher: read each message aloud prefixed with "the test failed because …". `failureMessage` completes that sentence when the property is absent; `negatedFailureMessage` completes it when the property is unexpectedly present. ## Lazy messages in Kotest 5 In Kotest 5 the companion factory is `MatcherResult(passed: Boolean, failureMessageFn: () -> String, negatedFailureMessageFn: () -> String)` — the messages are **lambdas**, evaluated only if the corresponding failure is raised. Kotest 4 took eager `String` arguments, meaning every passing assertion still paid to build two strings. Consequences in 5.x: - Expensive rendering (formatting a big list, computing a diff, serialising a domain object) costs nothing on the happy path, so you can afford *rich* messages. - The lambdas capture the value by reference. If the matcher is applied to mutable state that changes before the failure is rendered, the message can describe the state at throw time rather than at test time. Capture an immutable snapshot when that matters. ## What a good message contains A failure message must be readable by someone who did not write the matcher and cannot see the source. Include: 1. the **actual** value (rendered, not just its class), 2. the **expected** property in plain words, 3. enough discriminator to find the offending element in a collection. So `"\"$value\" should be a valid ISO-8601 date"` rather than `"invalid"`. The negated form is normally the same sentence with the polarity flipped: `"\"$value\" should not be a valid ISO-8601 date"`. ## The failure modes interviewers probe - **Copy-paste polarity bug** — the negated slot repeats the positive text. Every `shouldNot` failure then claims the opposite of what happened, and it is invisible until someone actually writes a negative assertion, often months later. Always test both directions of a custom matcher. - **Message omits the actual value** — the report says a rule was violated but not by what, forcing a debugger session for what should have been a glance at CI output. - **Throwing inside the matcher** — raising `AssertionError` directly from `test()` bypasses the `should` / `shouldNot` plumbing. That plumbing is where Kotest attaches contextual clues and where failures are collected instead of thrown in soft-assertion blocks, so a self-throwing matcher silently opts out of both and can never be negated or composed. - **Side effects in `test()`** — composition and inversion may call `test` more than once on the same value; a matcher that mutates or consumes (e.g. reads a stream) misbehaves under `and` / `or`. ## Richer results Beyond the plain result, Kotest offers `ComparableMatcherResult`, which carries the same passed flag and message lambdas **plus** the actual and expected values as strings. Tooling that understands comparison failures can then render a side-by-side diff instead of a single line — worth reaching for when your matcher compares large structures where "these two blobs differ" is useless without a diff. ## Bottom line The contract is: report, don't throw; fill in both polarities honestly; put the value in the text; and in Kotest 5 lean on the lazy lambdas to make messages generous rather than terse.

  • Why does Kotest 5 pass the messages as lambdas instead of strings?
    Because most assertions pass, and building a message for a passing assertion is wasted work. With `() -> String` the text is only produced when a failure is actually raised, so you can afford expensive, informative rendering — full collection dumps, diffs, formatted domain objects — without paying for it on every green assertion. The trade-off is that the lambda captures the value, so it renders whatever that reference holds at throw time.
  • Why should a custom matcher return a failed MatcherResult instead of throwing AssertionError itself?
    `should` and `shouldNot` are the layer that decides polarity, attaches contextual clues, and routes the failure — collecting it rather than throwing when the assertion runs inside a soft-assertion block. A matcher that throws on its own skips all of that: it cannot be negated, cannot be composed with `and`/`or`, and escapes collection. Returning a result keeps the matcher a pure predicate-with-messages.

saying these in an interview costs you the question

  • Filling negatedFailureMessage with a copy of the positive message
  • Believing the matcher itself decides whether to throw
  • Assuming negatedFailureMessage is optional because you only ever call should
  • Thinking messages are still eagerly built in Kotest 5 and therefore keeping them terse
  • Putting side effects or mutation inside test(), which composition may call twice

context