Spy + Any
expressive argument matching in clojure.test
People have asked more than once whether spy should have built-in argument matchers like they were used to in tools such as Mockito but I was always reluctant to add them, the reason being, I felt Clojure was expressive enough and it would add a lot of bloat to the library.
Recently, I noticed that my former colleague Ivan Grishaev released
any, a small library of values with custom equality
semantics. It is not a matcher framework bolted onto a test runner. It is just Clojure
values that know how to compare themselves via equiv. When I saw it I thought this
would pair well with spy.
When you use spy to verify that a function was called with certain arguments, you
sometimes care about the shape of those arguments more than their exact values.
Generated IDs and timestamps are values that your code produces, but they typically not
values you want to assert on.
Say we spy on a function that stores a record with a generated UUID and timestamp:
(let [f (spy/spy identity)
_ (f {:id (random-uuid)
:title "Nevermind"
:created-at (Instant/now)})
[[arg]] (spy/calls f)]
(is (uuid? (:id arg)))
(is (= "Nevermind" (:title arg)))
(is (inst? (:created-at arg))))
This works, but the structure of the map is scattered across three separate assertions and the test doesn’t read as a description of what we actually care about.
spy/called-with? compares arguments using Clojure’s =. any provides
values that implement IPersistentCollection/equiv with custom logic, so they
participate in = as matchers. There is no adapter layer, no protocol to extend, no
configuration needed. The same assertion becomes:
(let [f (spy/spy identity)
_ (f {:id (random-uuid)
:title "Nevermind"
:created-at (Instant/now)})]
(is (spy/called-with? f {:id any/uuid
:title "Nevermind"
:created-at any/Instant})))
Specify the values you control, use matchers for everything generated.
The intent is immediate. any gives you a toolkit of matcher
values and a macro to build your own. They all behave as first-class values so you
can embed them anywhere = is used.
any/string
any/uuid
any/int
any/float
any/keyword
any/symbol
any/map
any/vector
any/set
any/list
any/not-nil
(let [f (spy/spy (constantly nil))
_ (f {:to "Donovan" :body "Catch the wind"})]
(is (spy/called-with? f {:to any/string :body any/string})))
(let [f (spy/spy (constantly nil))
_ (f {:title "Hummer"
:genres [:rock :alternative]
:tags #{:active}})]
(is (spy/called-with? f {:title any/string
:genres any/vector
:tags any/set})))
;; Java time
any/Instant
any/LocalDate
any/LocalDateTime
any/LocalTime
any/OffsetDateTime
any/ZonedDateTime
any/Period
;; Byte arrays
any/bytes
;; String content
(any/includes "sub")
(any/starts-with "pre")
(any/ends-with "suf")
;; Regex — full match vs partial match
(any/re-matches #"Playing \".*\" now")
(any/re-find #"John Lennon")
;; Enum — documents which values are valid
(any/enum :pending :paid :failed)
;; Range — bounds are [from, to)
(any/range 1 1000)
;; Collection size
(any/count 3)
;; Instance
(any/instance clojure.lang.IPersistentMap)
;; UUID strings — for systems that pass UUIDs as strings
any/uuid-string
;; Custom — builds a matcher from any predicate; reusable as a var
(any/any [n "positive even int"] (and (int? n) (pos? n) (even? n)))
Ivan did a great job with any. The library is small,
focused, and composed well with spy without either side needing to know about the other,
which is exactly how good Clojure libraries should work.
If you’re using spy and you need argument matchers, try any. Full working examples can
be found in the any-spy repo.