skip to content

How do you write a reusable custom matcher in Kotest, and how do you compose matchers with and/or and negation?

level: seniorimportance: nice to knowfreq 30%

answer

  1. Implement Matcher<T> / return MatcherResult
  2. MatcherResult: passed + failureMessage + negatedFailureMessage
  3. Invoke via value should / shouldNot matcher
  4. Compose: and(), or(), invert(), compose()
  5. Two messages so shouldNot reads correctly

basics

~20 s

You create a small object implementing Kotest's Matcher interface, returning whether the value passed plus messages for failure and for the negated case. Then you can combine matchers with .and() / .or() and flip them with .invert().

solid answer

~40 s

A custom matcher implements `Matcher<T>` and overrides `test(value: T): MatcherResult`. `MatcherResult` carries `passed: Boolean`, a `failureMessage` (shown for `should`), and a `negatedFailureMessage` (shown for `shouldNot`). You then call it via `value should myMatcher` / `value shouldNot myMatcher`, or wrap it in an infix helper. Compose with combinators: `matcherA.and(matcherB)` (both must pass), `matcherA.or(matcherB)` (either), and `matcherA.invert()` to negate. There's a convenience `Matcher.invoke { ... }`-style factory and `MatcherResult(passed, { msg }, { negMsg })`. Custom matchers centralize domain assertions (e.g. `beAValidIban()`) with one good error message reused everywhere, and they slot into `assertSoftly` and collection helpers like `shouldHave`. The two-message design is what makes them work correctly under both `should` and `shouldNot`.

code

kotlin · 19 lines
kotlin
import io.kotest.matchers.Matcher
import io.kotest.matchers.MatcherResult
import io.kotest.matchers.should
import io.kotest.matchers.shouldNot

fun beValidSlug(): Matcher<String> = Matcher { value ->
    val ok = value.matches(Regex("[a-z0-9-]+"))
    MatcherResult(
        ok,
        { "'$value' should be a valid slug (lowercase, digits, hyphens)" },
        { "'$value' should not be a valid slug" },
    )
}

"my-post-1" should beValidSlug()
"Bad Slug" shouldNot beValidSlug()

val shortSlug = beValidSlug().and(Matcher { MatcherResult(it.length <= 20, { "too long" }, { "ok" }) })
"short" should shortSlug

go deeper

for a junior

Can use existing matchers but recognizes that custom ones implement Matcher and return a pass/fail with a message.

for a middle

Writes a basic custom matcher with both messages and invokes it via should/shouldNot.

for a senior

Composes matchers with and/or/invert/compose and wraps them in clean infix helpers; knows the two-message rationale.

for a principal

Builds a small library of domain matchers as a team asset, enforcing consistent, self-documenting failure messages across the suite.

## Why custom matchers When the same domain check appears across tests (a valid IBAN, a well-formed slug, a positive money amount), a **custom matcher** captures it once with a single clear failure message, instead of copy-pasting predicates. ## The Matcher interface ```kotlin import io.kotest.matchers.Matcher import io.kotest.matchers.MatcherResult import io.kotest.matchers.should import io.kotest.matchers.shouldNot fun bePositive(): Matcher<Int> = Matcher { value -> MatcherResult( value > 0, { "$value should be positive" }, // failureMessage (for should) { "$value should not be positive" } // negatedFailureMessage (for shouldNot) ) } 5 should bePositive() (-3) shouldNot bePositive() ``` - **`test(value)`** returns a `MatcherResult`. - **`passed`** — did it match? - **`failureMessage`** — printed when used with `should` and it failed. - **`negatedFailureMessage`** — printed when used with `shouldNot` and it failed. Providing both messages is essential: `shouldNot` reuses the matcher but needs the opposite wording. ## Wrapping in a readable infix To get `value shouldBePositive()` ergonomics you add a thin infix/extension: ```kotlin fun Int.shouldBePositive(): Int { this should bePositive(); return this } ``` ## Composition combinators Matchers compose: - **`a.and(b)`** — passes only if both `a` and `b` pass. - **`a.or(b)`** — passes if either passes. - **`a.invert()`** — logical negation (turns a matcher into its opposite, swapping the messages). - **`a.compose { transform }`** — adapt a `Matcher<U>` to a `Matcher<T>` by mapping the value first. ```kotlin val inRange = bePositive().and(beLessThan(100)) 50 should inRange ``` ## How it interoperates Because custom matchers go through the same `should`/`shouldNot` machinery, they work inside `assertSoftly`, can be negated, and produce the same structured failure output as built-ins. This is the extension point that keeps domain assertions DRY and self-documenting. ## Pitfalls Forgetting the `negatedFailureMessage` (or making both identical) yields confusing output under `shouldNot`. Putting heavy logic or side effects in `test` is also a smell — a matcher should be a pure predicate plus messages.

  • Why must a Matcher provide both failureMessage and negatedFailureMessage?
    So the same matcher reads correctly under both should (positive) and shouldNot (negated); each direction needs its own wording.
  • How do you make a Matcher<String> work against a domain type that wraps a String?
    Use compose { it.value } to map the domain value to the String the underlying matcher expects.
  • What's the difference between a.invert() and writing shouldNot a?
    invert() produces a new negated Matcher you can reuse/compose; shouldNot a is a call-site negation of the original matcher.

saying these in an interview costs you the question

  • Only supplying one message, so shouldNot output is misleading
  • Putting side effects or expensive work inside test()
  • Reimplementing and/or by hand instead of using the built-in combinators
  • Returning passed incorrectly so the matcher disagrees with its message

context