Testing thrown ex-info exceptions

In my code we often throw our own exceptions with

(throw (ex-info "Custom exception" {:cause "reasons" :data {}}))

When this hits our tests though,is there a way to meaningfully reason about its :data or :causes or something else? The usual (is (thrown? Exception ...)) doesn’t seem to foot the bill.

This is one of the reasons I like expectations.clojure.test – Expectations-style syntax but clojure.test compatible: https://github.com/clojure-expectations/clojure-test/blob/develop/doc/more.md#expect-more-

The example given is for using a regex on the thrown exception message:

  (is (thrown-with-msg? ArithmeticException #"Divide by zero" (/ 1 0)))
  (expect (more-> ArithmeticException type
                  #"Divide by zero"   ex-message)
          (/ 1 0))

but you could add a clause for ex-data in there and “expect” whatever predicates you want over that, or thread through it:

  (expect (more-> Exception    type
                  #"reasons"   (-> (ex-message) :cause))
          (some-expr))

You can use this Expectations library together with clojure.test, so the above could be in a deftest.

3 Likes

Note that thrown? and thrown-with-msg? are two default implementations of the clojure.test/assert-expr multimethod. You can make an implementation for handling clojure.lang.ExceptionInfo or the CLJS equivalent. This is a straightforward implementation that compares the data, but you probably want to make this check for sub-maps (does the data you expect occur in the data returned).

(require '[clojure.test :as t :refer [deftest is do-report]])

(defmethod t/assert-expr 'thrown-with-data? [msg form]
  (let [data (second form)
        body (nthnext form 2)]
    `(try [email protected]
          (do-report {:type :fail, :message ~msg,
                      :expected '~form, :actual nil})
          (catch clojure.lang.ExceptionInfo e#
            (let [expected# ~data
                  actual# (ex-data e#)]
              (if (= expected# actual#)
                (do-report {:type :pass, :message ~msg,
                            :expected expected#, :actual actual#})
                (do-report {:type :fail, :message ~msg,
                            :expected expected#, :actual actual#})))
            e#))))

(deftest foo
  (is (thrown-with-data? {:a 1} (throw (ex-info "" {:a 1}))))
  (is (thrown-with-data? {:b 1} (throw (ex-info "" {:a 1})))))

(t/run-tests) 
$ clj /tmp/test.clj

Testing user

FAIL in (foo) (test.clj:24)
expected: {:b 1}
  actual: {:a 1}

Ran 1 tests containing 2 assertions.
1 failures, 0 errors.

Bonus, also works in babashka:

$ bb /tmp/test.clj

Testing user

FAIL in (foo) (/private/tmp/test.clj:22)
expected: {:b 1}
  actual: {:a 1}

Ran 1 tests containing 2 assertions.
1 failures, 0 errors.
{:test 1, :pass 1, :fail 1, :error 0, :type :summary}
8 Likes

One thing to remember if you do this is that there is no namespacing for these multimethods. When we did this at a previous company I prefixed the multimethod with the company name to the assertion to avoid collisions, e.g. acme-thrown-with-data?.

1 Like