Expressive Clojure testing with the matcher-combinators library

A practical guide to writing expressive and flexible Clojure tests with matcher-combinators.

Aug 18, 2025

Like what you’re reading? Don’t miss out — hit subscribe and stay in the loop!

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 (via re-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-equals and set-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 embeds for large structures to only verify the fields that matter.

Performance considerations #

  • in-any-order and 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 with equals.
    • Matching a prefix or subset with prefix or embeds.
  • seq-of expects non-empty sequences. If emptiness is possible, combine with any-of (e.g., (any-of [] (seq-of ...))).

Troubleshooting and tips #

  • I matched a map, but extra keys were allowed — why? Maps default to embeds. Use nested-equals or wrap with m/equals if you need exact key matching.
  • My sequence test is slow: Check if you used in-any-order on a long sequence. Consider sorting the actual with via + equals.
  • My set test failed after adding a duplicate predicate: Use set-equals with a sequence to preserve duplicates (e.g., (m/set-equals [odd? odd? even?])).
  • My seq-of test fails on empty seqs: seq-of expects 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.

Like what you’re reading? Don’t miss out — hit subscribe and stay in the loop!