How do you write a reusable custom matcher in Kotest, and how do you compose matchers with and/or and negation?
answer
- Implement Matcher<T> / return MatcherResult
- MatcherResult: passed + failureMessage + negatedFailureMessage
- Invoke via value should / shouldNot matcher
- Compose: and(), or(), invert(), compose()
- Two messages so shouldNot reads correctly
basics
~20 sYou 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 sA 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 linesimport 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 shortSluggo deeper
Can use existing matchers but recognizes that custom ones implement Matcher and return a pass/fail with a message.
Writes a basic custom matcher with both messages and invokes it via should/shouldNot.
Composes matchers with and/or/invert/compose and wraps them in clean infix helpers; knows the two-message rationale.
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