Clojure Goodness: Extending is Macro With Custom Assertions
The is
macro in the clojure.test
namespace can be used to write assertions about the code we want to test. Usually we provide a predicate function as argument to the is
macro. The prediction function will call our code under test and return a boolean value. If the value is true
the assertion passes, if it is false
the assertion fails. But we can also provide a custom assertion function to the is
macro. In the clojure.test
package there are already some customer assertions like thrown?
and instance?
. The assertions are implemented by defining a method for the assert-expr
multimethod that is used by the is
macro. The assert-expr
multimethod is defined in the clojure.test
namespace. In our own code base we can define new methods for the assert-expr
multimethod and provide our own custom assertions. This can be useful to make tests more readable and we can use a language in our tests that is close to the domain or naming we use in our code.
The implementation of the custom assertion should call the function do-report
with a map containing the keys :type
, :message
, :expected
and :actual
. The :type
key can have the values :fail
or :pass
. Based on the code we write in our assertion we can set the value correctly. Mostly the :message
key will have the value of the message that is defined with the is
macro in our tests. The keys :expected
and :actual
should contain reference to what the assertion expected and the actual result. This can be a technical reference, but we can also make it a human readable reference.
In the following example we implement a new customer assertion jedi?
that checks if a given name is a Jedi name. The example is based on an example that can be found in the AssertJ documentation.
(ns mrhaki.test
(:require [clojure.test :refer [deftest is are assert-expr]]))
(defmethod assert-expr 'jedi?
"Assert that a given name is a Jedi."
[msg form]
`(let [;; We get the name that is the second element in the form.
;; The first element is the symbol `'jedi?`.
name# ~(nth form 1)
;; We check if the name is part of a given set of Jedi names.
result# (#{"Yoda" "Luke" "Obiwan"} name#)
;; We create an expected value that is used in the assertion message.
expected# (str name# " to be a jedi.")]
(if result#
(do-report {:type :pass
:message ~msg,
:expected expected#
:actual (str name# " is actually a jedi.")})
(do-report {:type :fail
:message ~msg,
:expected expected#
:actual (str name# " is NOT a jedi.")}))
result#))
;; We can use our custom assertion in our tests.
(deftest jedi
(is (jedi? "Yoda")))
;; The custom assertion can also be used with
;; the are macro as it will expand into multiple
;; is macro calls.
(deftest multiple-jedi
(are [name] (jedi? name)
"Yoda" "Luke" "Obiwan"))
;; The following test will fail, so we can
;; see failure message with the :expected and :actual values.
(deftest fail-jedi
(is (jedi? "R2D2") "Is it?"))
If we run our failing test we see in the output that the assertion message is using our definition of the expected and actual values:
...
expected: "R2D2 to be a jedi."
actual: "R2D2 is NOT a jedi."
...
Written with Clojure 1.11.3.