Portable Custom clojure.test Assertions
There’s quite a bit of extensibility in clojure.test. A variety of test runners and report formatters, such as James Reeves’ Eftest, Metosin’s boot-alt-test, and Paul Stadig’s Humane Test Output hook into the clojure.test reporting framework.
There’s also assert-expr
, which allows
you to extend the is
macro to encapsulate domain-specific testing,
as well as add domain-specific reporting. Many of clojure.test’s own
assertions, such as instance?
and thrown?
,
are implemented as assert-expr
methods. Daniel Solano Gómez has
written a library with helpers for Clojure spec called
tacular which includes a number of custom assertions. The
test.check library uses a custom assertion to improve
reporting in its defspec
testing macro.
Writing these custom assertions in a portable way can be a bit of a
trick, so let’s work through an example: an assertion that tests the
data returned from a thrown instance of ExceptionInfo
.
It’s often useful to start off with an idea of how the library is to be used, even prior to writing a single line of implementation.
Requiring the com.grzm.ex.assertions
namespace will make the
assertion available for the test. And we want it to be available in
both Clojure and ClojureScript, so we have the test in a cljc
file
and conditionally require both clojure.test
and cljs.test
.
The namespaced symbol com.grzm.ex.assertions/thrown-with-data?
will
dispatch to the custom assertion implementation. Extending the
assert-expr
multimethod is global for the running instance, not just
the file we require it in. Namespacing the symbol prevents us from
stomping on (or being stomped on by) another assertion dispatched by
the same unnamespaced symbol. Let’s play nice with others.
Clojure implementation
Let’s start out with the Clojure implementation in a clj
file.
So, let’s see if it works. In the Cider repl, I run
cider-test-run-ns-tests
.
Test Summary
com.grzm.ex.assertions-test
Tested 1 namespaces
Ran 1 assertions, in 1 test functions
1 passed
Grammar aside, the result looks good. Let’s see how failure reporting works. We have three cases to consider:
-
body
doesn’t throw -
body
throwsExceptionInfo
, but the data doesn’t match -
body
throws something other thanExceptionInfo
We handle the first two explicitly in the assertion. For the last, we won’t catch the exception, so we rely on clojure.test’s default error handling. Let’s add tests for these cases.
And the test results:
Test Summary
com.grzm.ex.assertions-test
Tested 1 namespaces
Ran 4 assertions, in 4 test functions
2 failures
1 errors
Results
com.grzm.ex.assertions-test
1 non-passing tests:
Fail in test-thrown-with-data-fail
ex-data doesn't match
expected: (fn*
[p1__14287#]
(= :com.grzm.ex.assertions-test/nope (:cause p1__14287#)))
actual: {:cause :com.grzm.ex.assertions-test/self-inflicted}
com.grzm.ex.assertions-test
1 non-passing tests:
Fail in test-thrown-with-data-no-throw
expected exception
expected: (com.grzm.ex.assertions/thrown-with-data?
(fn*
[p1__14278#]
(= :com.grzm.ex.assertions-test/nope (:cause p1__14278#)))
(constantly true))
actual: nil
com.grzm.ex.assertions-test
1 non-passing tests:
Error in test-thrown-with-data-non-ex-info
expected: (com.grzm.ex.assertions/thrown-with-data?
(fn*
[p1__14296#]
(= :com.grzm.ex.assertions-test/nope (:cause p1__14296#)))
(throw (Exception. "I'm just a plain Exception")))
error: java.lang.Exception: I'm just a plain Exception
Looks pretty good. We have different messages depending on the failure
mode. We can see the anonymous functions used to match the
ExceptionInfo
data. In test-thrown-with-data-fail
we have the
actual data. When we throw with something other than ExceptionInfo
,
we get an error, not a failure.
ClojureScript implementation
This assertion won’t work for ClojureScript, however. We need to
extend cljs.test/assert-expr
(which has
three arguments) instead of clojure.test/assert-expr
(which has
two), as well as catch cljs.core/ExceptionInfo
instead of
clojure.java.ExceptionInfo
. Another wrinkle is that is
is a macro:
the assertion needs to be available to is
, so we need to define the
assertion in a clj
file.
So, we need to compile a clj
file that will be used only in the
cljs
environment. That can’t be the assertions.clj
file, as we
need that one for the Clojure target. So, we’ll make a separate clj
file for the ClojureScript target: assertions/cljs.clj
.
Note we’re requiring cljs.test
, not clojure.test
. Elsewhere,
we’re relying on ClojureScript/Clojure namespace
aliasing. Compilation here is in Clojure,
and clojure.test/assert-expr
is not the same as
cljs.test/assert-expr
. We’re extending cljs.test/assert-expr
, so
we need to refer to it explicitly.
And, as we want to use the same com.grzm.ex.assertions
namespace to
include the assertion, we’ll create a separate cljs
file to require
the ClojureScript macros.
We also need to update the test where we throw an exception: we’ll
throw js/Error
instead of java.lang.Exception
.
After setting up the a lein cljsbuild
environment for testing with a
Node.js runner, here are the test results:
λ lein clean && lein cljsbuild once
Compiling ClojureScript...
Compiling "target/cljs/node_dev/tests.js" from ["src" "test/src" "test/node/src"]...
Testing com.grzm.ex.assertions-test
FAIL in (test-thrown-with-data-no-throw) (cljs/test.js:443:85)
expected exception
expected: (com.grzm.ex.assertions/thrown-with-data? (fn* [p1__11833#] (= :com.grzm.ex.assertions-test/nope (:cause p1__11833#))) (constantly true))
actual: nil
FAIL in (test-thrown-with-data-fail) (cljs/test.js:443:85)
ex-data doesn't match
expected: (fn* [p1__11837#] (= :com.grzm.ex.assertions-test/nope (:cause p1__11837#)))
actual: {:cause :com.grzm.ex.assertions-test/self-inflicted}
ERROR in (test-thrown-with-data-non-ex-info) (Error:NaN:NaN)
expected: (com.grzm.ex.assertions/thrown-with-data? (fn* [p1__11841#] (= :com.grzm.ex.assertions-test/nope (:cause p1__11841#))) (throw (js/Error. "I'm just a plain Error")))
actual: #object[Error Error: I'm just a plain Error]
Ran 4 tests containing 4 assertions.
2 failures, 1 errors.
Successfully compiled "target/cljs/node_dev/tests.js" in 3.412 seconds.
Looks very similar to the Clojure results.
We still have quite a bit of code duplication. Let’s DRY that up. We
can combine assertions.clj
and assertions.cljs
into a single
assertions.cljc
file with reader conditionals.
And we can extract the common logic from the assert-expr
method
implementations. Note we need to pass in the class of exception we’re
catching as it’s different depending on the target.
And that allows us to clean up the assert-expr
method implementations.
So, we have three files (excluding tests):
-
src/com/grzm/ex.assertions.cljc
- namespace which loads the assertion -
src/com/grzm/ex/assertions/cljs.clj
- ClojureScript-target macros -
src/com/grzm/ex/assertions/impl.clj
- common Clojure/ClojureScript code
We have custom reporting depending on the failure case which improves
on the clojure.test
default behavior, and we can use the assertion in
both Clojure and ClojureScript.
Self-hosted ClojureScript
But what about self-hosted ClojureScript? We need all three of these
files to load in a ClojureScript-only environment. We need cljs.clj
and impl.clj
to be cljc
files so they’re loaded during self-hosted
(i.e., ClojureScript) compilation. For impl.clj
, it’s simply a name
change. But the assertion implementation is a bit more involved.
Compiling the ClojureScript target with the JVM requires a clj
file. This won’t be loaded when self-hosted. We need to implement the
assertion in ClojureScript during ClojureScript compilation but only
when self-hosted.
How can we determine the compilation environment? One way is to
detect the existence of objects that exist only in the self-hosted
environment. One of those is js/cljs.test$macros
. And we do this in
the com.grzm.ex.assertions
namespace. This is not support for the
ClojureScript target in Clojure: it’s ClojureScript we’re loading
during self-hosted ClojureScript compilation.
The com.grzm.ex.assertions.cljs
namespace is still required during
ClojureScript compilation, so we need to make that available as a cljc
file. We don’t want the assertion implementation however, so we wrap
that in a reader conditional.
After setting up the appropriate self-hosted test environment, let’s see how it works.
λ scripts/test-self-host
Testing com.grzm.ex.assertions-test
FAIL in (test-thrown-with-data-no-throw) (at .js:431:14)
expected exception
expected: (com.grzm.ex.assertions/thrown-with-data? (fn* [p1__17#] (= :com.grzm.ex.assertions-test/nope (:cause p1__17#))) (constantly true))
actual: nil
FAIL in (test-thrown-with-data-fail) (at .js:431:14)
ex-data doesn't match
expected: (fn* [p1__18#] (= :com.grzm.ex.assertions-test/nope (:cause p1__18#)))
actual: {:cause :com.grzm.ex.assertions-test/self-inflicted}
ERROR in (test-thrown-with-data-non-ex-info) (Error:NaN:NaN)
expected: (com.grzm.ex.assertions/thrown-with-data? (fn* [p1__19#] (= :com.grzm.ex.assertions-test/nope (:cause p1__19#))) (throw (js/Error. "I'm just a plain Error")))
actual: #object[Error Error: I'm just a plain Error]
Ran 4 tests containing 4 assertions.
2 failures, 1 errors.
Success! (It’s not often you say that when the output ends with “2 failures, 1 errors”.) And a quick check of our Clojure and JVM-ClojureScript tests confirms they still work as expected.
Wrapping up
I’m a fan of testing and am always looking for ways to write more expressive tests and improve test reporting. This lead me to test.check and writing, with Gary Fredericks’ guidance, a custom assertion to improve reporting in both Clojure and ClojureScript. Mike Fikes added support for self-hosted ClojureScript.
Here I’ve applied that same pattern to write another assertion. You can find the completed code along with the supporting test environments on Github. (Thanks to Mike Fikes again for providing an example of an environment for self-hosted ClojureScript.) I’ve also collected a small set of custom assertions I use in the Tespresso library (which I’m in the process of porting to ClojureScript). I hope this provides an self-contained example that encourages others to write assertions they find useful.
Thanks
Thank you to Mike Fikes for reviewing this post and reminding me about ClojureScript/Clojure namespace aliasing. You can read more about that on his blog.