Home

Awesome

matcher-combinators

Library for making assertions about nested data structures.

current version:

Current Version join chat

docs: Found on cljdoc

Clojure version compatibility: 1.8 and up

Stewards

matcher-combinators is maintained by:

For questions and more info, please use the Clojurians channel #matcher-combinators.

Motivation

Clojure's built-in data structures get you a long way when trying to codify and solve difficult problems. A solid selection of core functions allow you to easily create and access core data structures. Unfortunately, this flexibility does not extend to testing: we seem to be missing a comprehensive yet extensible way to assert that the data fits a particular structure.

This library addresses this issue by providing composable matcher combinators that can be used as building blocks to test functions that evaluate to nested data-structures more effectively.

Features

Usage

clojure.test

Require the matcher-combinators.test namespace, which will extend clojure.test's is macro to accept the match? and thrown-match? directives.

For example:

(require '[clojure.test :refer [deftest is]]
         '[matcher-combinators.test] ;; adds support for `match?` and `thrown-match?` in `is` expressions
         '[matcher-combinators.matchers :as m])

(deftest test-matching-with-explicit-matchers
  (is (match? (m/equals 37) (+ 29 8)))
  (is (match? (m/regex #"fox") "The quick brown fox jumps over the lazy dog")))

(deftest test-matching-scalars
  ;; most scalar values are interpreted as an `equals` matcher
  (is (match? 37 (+ 29 8)))
  (is (match? "this string" (str "this" " " "string")))
  (is (match? :this/keyword (keyword "this" "keyword")))
  ;; regular expressions are handled specially
  (is (match? #"fox" "The quick brown fox jumps over the lazy dog")))

(deftest test-matching-sequences
  ;; A sequence is interpreted as an `equals` matcher, which specifies
  ;; count and order of matching elements. The elements, themselves,
  ;; are matched based on their types.
  (is (match? [1 3] [1 3]))
  (is (match? [1 odd?] [1 3]))
  (is (match? [#"red" #"violet"] ["Roses are red" "Violets are ... violet"]))

  ;; use m/prefix when you only care about the first n items
  (is (match? (m/prefix [odd? 3]) [1 3 5]))

  ;; use m/in-any-order when order doesn't matter
  (is (match? (m/in-any-order [odd? odd? even?]) [1 2 3]))

  ;; NOTE: in-any-order is O(n!) because it compares every expected element
  ;; with every actual element in order to find a best-match for each one,
  ;; removing matched elements from both sequences as it goes.
  ;; Avoid applying this to long sequences.
  )

(deftest test-matching-sets
  ;; A set is also interpreted as an `equals` matcher.
  (is (match? #{1 2 3} #{3 2 1}))
  (is (match? #{odd? even?} #{1 2}))
  ;; use m/set-equals to repeat predicates
  (is (match? (m/set-equals [odd? odd? even?]) #{1 2 3}))

  ;; NOTE: matching sets is an O(n!) operation because it compares every
  ;; expected element with every actual element in order to find a best-match
  ;; for each one, removing matched elements from both sets as it goes.
  ;; Avoid applying this to large sets.
  )

(deftest test-matching-maps
  ;; A map is interpreted as an `embeds` matcher, which ignores
  ;; un-specified keys
  (is (match? {:name/first "Alfredo"}
              {:name/first  "Alfredo"
               :name/last   "da Rocha Viana"
               :name/suffix "Jr."}))))

(deftest test-matching-nested-datastructures
  ;; Maps, sequences, and sets follow the same semantics whether at
  ;; the top level or nested within a structure.
  (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 []})))

(deftest test-matching-transformed-value-via-via
  ;; via applies read-string to the actual value "{:foo :bar}" before
  ;; matching against the expected value {:foo :bar}
  (is (match? {:payloads [(m/via read-string {:foo :bar})]}
              {:payloads [\"{:foo :bar}\"]})))

(deftest exception-matching
  (is (thrown-match? clojure.lang.ExceptionInfo
                     {:foo 1}
                     (throw (ex-info "Boom!" {:foo 1 :bar 2})))))

Midje:

We've deprecated support for Midje in matcher-combinators. We continue to ship with the matcher-combinators.midje namespace to avoid breaking changes, but we no longer include midje as a transitive dependency.

Standalone:

The matcher-combinators.standalone namespace provides an API for using matcher-combinators outside the context of a test framework.

Matchers

Default matchers

When an expected value isn't wrapped in a specific matcher the default interpretation is:

You can use the matcher-for function to discover which matcher would be used for a specific value, e.g.

(require '[matcher-combinators.matchers :as matchers])

(matchers/matcher-for {:this :map})
;; => #function[matcher-combinators.matchers/embeds]

built-in matchers

via matcher: transform the actual before matching

In some cases one might want to match a serialized string against a parsed data-structure.

Without help this might look like the following, which becomes tedious for deeply nested structures:

(let [result {:payloads ["{:foo :bar :baz :qux}"]}]
 (is (match? {:payloads [{:foo :bar}]}
      (update result :payloads (partial map read-string)))))

The via matcher can help us out with this:

(let [result {:payloads ["{:foo :bar :baz :qux}"]}]
  (is (match? {:payloads [(m/via read-string {:foo :bar})]}
              {:payloads result})))

via, when paired with match-with, can be used to apply actual pre-processing before applying an underlying matcher:

(testing "using `match-with` + `via` we can sort the actual result before matching"
  (is (match? (m/match-with
               [vector? (fn [expected] (m/via sort expected))]
               {:payloads [1 2 3]})
              {:payloads (shuffle [3 2 1])}))))

In this example we decorate vector?'s matcher to first sort the actual and then do matching. When operating over sort-able values this can be a stand-in for the computationally slower in-any-order.

negative matchers

Negative matchers, that is, those asserting the absence of something, are generally discouraged due to the adverse effect they can have on code readability.

readability concerns with negation matchers
(deftest avoid-negative-matchers
  (testing "normal assertion that `:a` is present"
    (match? {:a any?}
            actual))
  (testing "double negation version"
    (match? (matcher-combinators.matchers/mismatch {:a matcher-combinators.matchers/absent})
            actual)))

building new matchers

You can extend your data-types to work with matcher-combinators by implemented the Matcher protocol.

In the Matcher protocol -name and -matcher-for are largely boilerplate while the important implementation is -match, who should return a map adhering to the result spec.

Overriding default matchers

Inside the context of match? (clojure.test) / match (midje), data-structures are assigned default matchers, which eliminates the need to wrap data-structures with matcher-combinators when your desired matching behavior matches the defaults.

But what if your desired matching behavior deviates from the defaults?

For example, if you want to do exact map matching you need to use a log of m/equals:

(deftest exact-map-matching-by-hand
  (is (match? (m/equals {:a (m/equals {:b (m/equals {:c odd?})})})
              {:a {:b {:c 1}}}))
  ;; without m/equals, the system defaults to m/embeds for maps,
  ;; which has looser matching properties
  (is (match? {:a {:b {:c odd?}}}
              {:a {:b {:c 1 :extra-c 0} :extra-b 0} :extra-a 0})))

For convenience we've also added the built-in matcher nested-equals to reduce this verbosity:

(deftest exact-map-matching-with-match-with
  (is (match? (m/nested-equals {:a {:b {:c odd?}}}))
              {:a {:b {:c 1}}}))

Development

Start nREPL

bb dev

(requires babashka to run bb commands)

Running tests

The project contains midje, clojure.test, and cljs.test tests.

bb test:clj   # run only Clojure tests
bb test:midje # run only Midje tests
bb test:node  # run only ClojureScript tests
bb test:browser # run ClojureScript tests in browser at `http://localhost:9158/`

Linting and formatting

Check formatting and linting:

bb lint

Auto-fix formatting and linting:

bb lint:fix