← Back to posts

Understanding Clojure Multimethod from clojure.test

In the previous post, we briefly talked about the syntax used in writing tests with clojure.test. Specifically, we touched upon how thrown? is one implementation of a multimethod, which allows you to add more assertion expressions that might suite better the domain language of your problem.

When I started learning Clojure, the concept of multimethod was always a bit slippery to grasp. I guess this is because there wasn't any close parallel from other languages I already know. Hence this post is to explain the mental model I use when looking at multimethod in Clojure, with the help of the source code of clojure.test.

A normal function

In Clojure, a normal function is pretty straightforward:

(defn greet [name]
  (str "Hello, " name))

(greet "John Doe") ;; Hello, John Doe

This looks like any other function in languages you already know, although in OO languages, this function would normally be a method of a class, which brings in the context of this or self during invocation. Nevertheless, it's safe to say that my brain isn't hurting when looking at this piece of code. It's quite clear what happens when I call this function.

A Multimethod

A multimethod, however, introduces a level of indirection. It consists of two parts, defmulti (single) and defmethod (one or more). This level of indirection is then deliberately used to allow different behaviors based on some value. This point first draws my mind toward polymorphism in OO. However, there is no class or interface here, hence it's not 100% identical to how you would swap implementation in OO languages.

;; Step 1 - define the method, it takes a function, `greet` in this example, which will be run against argument(s) in Step 3 below.           
(defmulti greeting greet)

;; Step 2 - define possible implementations based on the result of `greet` in step 1
(defmethod greeting "Hello, John"
  [_]
  "John Locke was an English philosopher and physician")

(defmethod greeting "Hello, Jack"
  [_]
  "Jack Reacher is the protagonist of a series of crime thriller novels by British author Lee Child")

(defmethod greeting :default
  [_]
  "I don't now who this person is")

;; Step 3 - now we use the method
(greeting "John") ;; "John Locke was an English philosopher and physician"
(greeting "Jack") ;; "Jack Reacher is the protagonist of a series of crime thriller novels by British author Lee Child"
(greeting "YZ")   ;; "I don't now who this person is"

At first glance, multimethod reminds me of traditional switch statement. I basically switch to different behavior depending on the result of calling greet on the argument. If no case is matched, then I run the :default scenario.

However, there is one big difference and advantage of multimethod over switch statement. In Java or JavaScript, When I use a 3rd-party library's API, which dispatches based on its switch statement, there is no way I could just add my new case into that method (I have no access to that code!), and dynamically add a new behavior. In Clojure, this is possible.

(ns my-project
  (:require [third-party-lib-above :refer [greeting]]))

(defmethod greeting "Hello, David"
  [_]
  "David Hume was a Scottish Enlightenment philosopher, historian, economist, librarian and essayist")

(greeting "David") ;; "David Hume was a Scottish Enlightenment philosopher, historian, economist, librarian and essayist"

This is a powerful "switch" statement. It's functional, and dynamically dispatched, all based on a single value. In OO languages, I would need to write my own class that implements an interface from 3rd party code.

How multimethod is used in clojure.test

Looking at the source code, thrown? is an implementation of the multimethod, which is used by is macro. A lot of the code below is syntax for macros.

;; Step 1 - definition of multimethod - the `defmulti` part
(defmulti assert-expr 
  (fn [msg form]
    (cond
      (nil? form) :always-fail
      (seq? form) (first form)  ;; -> here, taking the first in the sequence which can be 'thrown? value
      :else :default)))

;; Step 2 - simplified definition of `thrown?` - the `defmethod` part
(defmethod assert-expr 'thrown? [msg form]
  (let [klass (second form)
        body (nthnext form 2)]
    `(try ~@body
          (do-report {:type :fail, :message ~msg,
                   :expected '~form, :actual nil})
          (catch ~klass e#
            (do-report {:type :pass, :message ~msg,
                     :expected '~form, :actual e#})
            e#))))

;; Step 3 - use it
;; simplified definition of `is`
(defmacro is
  ([form msg] `(try-expr ~msg ~form)))  ;; try-expr used here

;; simplified definition of `try-expr`
(defmacro try-expr
  [msg form]
  `(try ~(assert-expr msg form)  ;; calling `assert-expr` multimethod here
        (catch Throwable t#
          (do-report {:type :error, :message ~msg,
                      :expected '~form, :actual t#}))))

But wait a minute, where is the function call of (thrown? IllegalStateException (save (get-todoist nil) 1))? Isn't thrown? a function? Well, it's actually just the first form of the list (thrown? Exception (fn-call)), and that matches the definition of assert-expr in the case of 'thrown? value.

Let's add a new assertion!

;; we just use a keyword here
(defmethod assert-expr ::polite? [msg form]
  (let [s (last form)]
    `(do-report {:type (if (re-find #"(?i)hello" ~s) :pass :fail), :message ~msg})))

(deftest new-assertion
  (testing "contains hello"
    (is (::polite? "Hello world!")))
  (testing "has no hello"
    (is (::polite? "Bonjour"))))

(run-test new-assertion)

How to add the same assertion in Jest?

// tldr: `expect` has a `extend` method which allows you to add new matchers
expect.extend({
  toContainHello: (received) => {
    return {
      pass: received.toLowerCase().includes("hello"),
      message: () =>
        `Hmm looks like "${received}" does not contain hello`,
    };
  },
});

describe("custom matcher", () => {
  it("should pass", () => {
    expect("Hello world!").toContainHello();
  });

  it("should fail", () => {
    expect("Bonjour!").toContainHello();
  });
});

Clojure Reference on Multimethod

This is a good article to read, which also talks about hierarchy with multimethod. One thing to note from the reference is that, matching of value is not using =:

Multimethods use isa? rather than = when testing for dispatch value matches. Note that the first test of isa? is =, so exact matches work.

You can read more here.

HomeBlogContactGithubReading