Search

Dark theme | Light theme

May 21, 2024

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.