The matcher-combinators is a Clojure library that helps you make expressive assertions about nested data structures. It extends clojure.test framework so you can declare the shape of data with readable, composable matchers and get pretty diffs when an assertion fails.
Why matcher-combinators? #
Plain equality is often too strict for tests involving nested maps, sequences, and sets. The matcher-combinators library lets you:
- Match only the parts you care about and ignore the rest.
- Combine predicates, regexes, and structural matchers.
- See clear, colorized diffs when a match fails.
It turns expectations into declarative matchers that read like the shape of your data.
Practical example #
Imagine you have a function that saves some data to the database and returns the stored object. Along with the original fields, it usually adds system-generated values like id, created_at, and updated_at.
In tests, you can’t predict these values ahead of time. That means you often end up stripping out the extra fields or asserting them separately—for example, checking that id exists and that created_at is a valid timestamp.
With matcher-combinators, you don’t need all that boilerplate. Instead, you can describe the shape of the expected result and focus only on the fields you care about. Beyond simple equality, you can assert presence, types, and other structural properties.
(ns matcher-combinators-demo.core-test
(:require [clojure.test :refer :all]
[matcher-combinators-demo.core :refer :all]
[matcher-combinators.test])
(:import (java.time Instant)))
(defn store-item
[item]
(merge item
{:id (random-uuid)
:createdAt (Instant/now)
:updatedAt (Instant/now)}))
(deftest store-item-test
(testing "without matchers"
(let [name "something"
quantity 10
stored-item (store-item {:name name
:quantity quantity})]
(is (= {:quantity 10
:name name} (dissoc stored-item :id :createdAt :updatedAt)))
(is (uuid? (:id stored-item)))
(is (instance? Instant (:createdAt stored-item)))
(is (instance? Instant (:updatedAt stored-item)))))
(testing "with matchers"
(let [name "something"
quantity 10
stored-item (store-item {:name name
:quantity quantity})]
(is (match? {:id uuid?
:quantity quantity
:name name
:createdAt inst?
:updatedAt inst?} stored-item)))))
Installation #
Get the latest version from Clojars: https://clojars.org/nubank/matcher-combinators
With deps.edn:
nubank/matcher-combinators {:mvn/version "3.9.1"}
With project.clj:
[nubank/matcher-combinators "3.9.1"]
Quick start with clojure.test #
The matcher-combinators library integrates with clojure.test by adding new directives to is:
match?for asserting a match between expected (matcher) and actual (value/expression)thrown-match?for matching the exception type and its data
Require the support namespace once in your test ns to activate it.
(ns my.project.core-test
(:require [clojure.test :refer [deftest is testing]]
[matcher-combinators.test]
[matcher-combinators.matchers :as m]))
(deftest scalars-and-regex
;; Scalars default to equals
(is (match? 37 (+ 29 8)))
(is (match? "this string" (str "this" " " "string")))
(is (match? :this/keyword (keyword "this" "keyword")))
;; Regex defaults to regex matching via re-find
(is (match? #"fox" "The quick brown fox jumps over the lazy dog")))
(deftest explicit-matchers
(is (match? (m/equals 37) (+ 29 8)))
(is (match? (m/regex #"fox") "The quick brown fox jumps over the lazy dog")))
(deftest exceptions
(is (thrown-match? clojure.lang.ExceptionInfo
{:foo 1}
(throw (ex-info "Boom!" {:foo 1 :bar 2}))))
How defaults work #
When you embed raw values inside a match? expectation, matcher-combinators library applies default matchers:
- Scalars (numbers, strings, keywords, booleans), vectors, lists, sets: default to
equals. - Regex: defaults to
regex(viare-find). - Maps: default to
embeds(subset, ignore unspecified keys).
Examples:
; vectors (equals): order and length matter
(is (match? [1 odd?] [1 3]))
; sets (equals): multiset-ish matching; use set-equals for duplicates via seq
(is (match? #{odd? even?} #{1 2}))
; maps (embeds): only care about keys you specify
(is (match? {:name/first "Alfredo"}
{:name/first "Alfredo"
:name/last "da Rocha Viana"
:name/suffix "Jr."}))
Nested behaviour follows the same rule: inside a map, sub-maps also default to embeds, while nested vectors/sets default to equals unless overridden.
Core structural matchers #
equals: strict structural equality for all types.nested-equals: like equals, but also overrides map defaults so nested maps use equals (not embeds).embeds: subset matching for maps, sequences, and sets (order-agnostic subset for sequences and sets).prefix: matches the first n items in a sequence.in-any-order: sequence matcher ignoring order (O(n!) complexity; avoid on long sequences).set-equalsandset-embeds: like equals/embeds for sets, but you specify matchers using a sequence to allow duplicates.seq-of: every element of a sequence must match the given matcher (expects non-empty sequences).any-of: succeeds if any one of the given matchers matches.all-of: succeeds if all of them match.regex: uses re-find against string actual value.within-delta: numeric tolerance matcher: expected ± delta.via: transform the actual before matching (very useful for parsing, sorting, or normalisation).match-with: override default matchers based on predicates (e.g. make all vectors sort before comparison).
Examples across data types:
; sequences
(is (match? (m/prefix [odd? 3]) [1 3 5]))
(is (match? (m/in-any-order [odd? odd? even?]) [1 2 3]))
; sets
(is (match? (m/set-equals [odd? odd? even?]) #{1 2 3}))
; nested datastructures
(is (match? {:band/members [{:name/first "Alfredo"}
{:name/first "Benedito"}]}
{:band/members [{:name/first "Alfredo"
:name/last "da Rocha Viana"
:name/suffix "Jr."}
{:name/first "Benedito"
:name/last "Lacerda"}]
:band/recordings []}))
; transforming actual values with via
(let [result {:payloads ["{:foo :bar :baz :qux}"]}]
(is (match? {:payloads [(m/via read-string {:foo :bar})]}
result)))
; sorting actual vectors using match-with + via (can avoid expensive in-any-order)
(is (match? (m/match-with
[vector? (fn [expected] (m/via sort expected))]
{:payloads [1 2 3]})
{:payloads (shuffle [3 2 1])}))
; numeric tolerance
(is (match? (m/within-delta 3.14 0.01) 3.145))
Overriding defaults (equals vs embeds for maps) #
Maps default to embeds, which is powerful, but sometimes you want exact matching everywhere.
Naive approach (very verbose):
(is (match? (m/equals {:a (m/equals {:b (m/equals {:c odd?})})})
{:a {:b {:c 1}}}))
; Without m/equals, the default embeds would allow extra keys:
(is (match? {:a {:b {:c odd?}}}
{:a {:b {:c 1 :extra-c 0} :extra-b 0} :extra-a 0}))
Better approach: use nested-equals to apply equals across nested maps:
(is (match? (m/nested-equals {:a {:b {:c odd?}}})
{:a {:b {:c 1}}}))
Negative matchers #
- mismatch: negates a matcher; passes when the inner matcher fails.
- absent: for maps; matches only if a key is absent.
Readability note: negative logic can be harder to read; prefer positive assertions when possible.
; prefer
(is (match? {:a any?} actual))
; avoid double-negation constructions
(is (match? (m/mismatch {:a m/absent}) actual))
Exception matching with thrown-match? #
thrown-match? lets you assert both the exception class and its ex-data (or other properties via matchers):
(is (thrown-match? clojure.lang.ExceptionInfo
{:foo 1}
(throw (ex-info "Boom!" {:foo 1 :bar 2}))))
Pretty diffs that help you fix tests faster #
When a match fails, matcher-combinators prints a readable, colorized diff highlighting additions, removals, and mismatches. This is particularly helpful for large maps or nested collections. You don’t need to do anything special to enable this—just use match? and read the failure output.
Tips for readable diffs:
- Prefer explicit matchers (e.g.,
seq-of,in-any-order) when intent matters. - Narrow the focus: use
embedsfor large structures to only verify the fields that matter.
Performance considerations #
in-any-orderand matching sets can be O(n!) because they try to find best matching pairs. Avoid for long collections. Consider:- Sorting the actual using
via(e.g.,m/via sort expected) combined withequals. - Matching a prefix or subset with
prefixorembeds.
- Sorting the actual using
seq-ofexpects non-empty sequences. If emptiness is possible, combine withany-of(e.g.,(any-of [] (seq-of ...))).
Troubleshooting and tips #
- I matched a map, but extra keys were allowed — why? Maps default to
embeds. Usenested-equalsor wrap withm/equalsif you need exact key matching. - My sequence test is slow: Check if you used
in-any-orderon a long sequence. Consider sorting the actual withvia+equals. - My set test failed after adding a duplicate predicate: Use
set-equalswith a sequence to preserve duplicates (e.g.,(m/set-equals [odd? odd? even?])). - My
seq-oftest fails on empty seqs:seq-ofexpects non-empty; combine with(any-of [] (seq-of ...))if empty is acceptable. - How do I see what default matcher is used?
(matcher-combinators.matchers/matcher-for value)returns the matcher function.
Putting it all together #
Suppose your function returns a rich API response:
(defn fetch-user [id]
;; imagine it calls an external service
{:user/id id
:user/name {:first "Alfredo" :last "da Rocha Viana"}
:user/roles ["admin" "editor"]
:http {:status 200 :headers {"x-request-id" "abc"}}
:raw "{:foo :bar :baz :qux}"})
(deftest response-shape
(is (match? {:user/id int?
:user/name {:first #"^[A-Z].+"}
:user/roles (m/in-any-order ["editor" "admin"])
:http {:status 200}
:raw (m/via read-string {:foo :bar})}
(fetch-user 123))))
This test:
- Checks exact id type, name format via regex, unordered roles, subset of http fields, and parses the raw string before matching.
- Will produce a helpful diff if any of these assumptions are violated.
Conclusion #
The matcher-combinators library brings clarity and power to tests that assert shapes of data. Start simple with default matchers, then leverage structural matchers like embeds, nested-equals, seq-of, and helpers like via and match-with as your needs grow. With better diffs and less boilerplate, you’ll write tests that are both robust and readable.