-
Make sure your clojure(script) versions are at or above "1.9.x".
-
Add [fulcrologic/fulcro-spec "x.y.z"]
to your :dependencies
.
-
Make sure you have at least one test file, eg: test/your-ns/arithmetic_spec.cljc
, that uses fulcro-spec.core
:
(ns your-ns.arithmetic-spec
(:require
[fulcro-spec.core :refer [specification behavior component assertions]]))
(specification "arithmetic"
(component "addition"
(behavior "is commutative"
(assertions
"description of the assertion(s). A string can be followed by 1+ arrow triples:"
(+ 13 42) => (+ 42 13)
(+ 1 5) => 6
"Another assertion"
(+ 1 1) => 2))))
The specification
macro is just deftest
, but it allows you to use a string instead of a symbol (it optionally also
allows tests to be tagged for filtering). The component
and behavior
are roughly an alias for testing
,
but will indent within a Fulcro Spec reporter (so you get an outline view).
The assertions
macro rewrites REPL-like expressions into clojure.test/is
calls, but supports some shorthands for
common checks (e.g. =throws⇒
can be used to expression the emission of an exception). It allows groups of assertions
to include a description that will also be emitted (in outline form) in the reporter.
The above test is nearly equivalent to:
(ns your-ns.arithmetic-spec
(:require
[clojure.test :refer [deftest is testing]]))
(deftest arithmetic
(testing "addition"
(testing "is commutative"
(testing "description of the assertion(s). A string can be followed by 1+ arrow triples:"
(is (= (+ 13 42) (+ 42 13)))
(is (= (+ 1 5) 6)))
(testing "Another assertion"
(is (= (+ 1 1) 2))))))
In fact, if you are used to testing with your REPL, you may choose to use deftest
instead, since editor integration
often looks for the deftest
symbol itself:
(ns your-ns.arithmetic-spec
(:require
[clojure.test :refer [deftest]]
[fulcro-spec.core :refer [specification behavior component assertions]]))
;; Ok to mix and match. This form is easier to use with REPL integration
(deftest arithmetic
(assertions
"description of the assertion(s). A string can be followed by 1+ arrow triples:"
(+ 13 42) => (+ 42 13)
(+ 1 5) => 6
"Another assertion"
(+ 1 1) => 2))
You can check for thrown exceptions with =throws⇒
, which accepts a few different forms on the right-hand side. The
easiest and most useful is a map with a :regex
key for checking the message in the exception:
(ns your-ns.arithmetic-spec
(:require
[fulcro-spec.core :refer [specification behavior component assertions]]))
(specification "arithmetic"
(assertions
(f 22) =throws=> {:regex #"something in exception message"}))
Testing units of code in isolation is a critical aspect of sustainable testing, and is in fact the majority of the
reason that fulcro-spec
exists. It is not uncommon to continue to write tests with clojure.test/deftest
and
clojure.test/is
because IDE’s like Cursive support them very well (though you lose the usefulness of
fulcro spec’s improved reporter). However, dealing with isolating your code typically involves a lot of hassle and
boilerplate.
Isolating a unit of code is where fulcro-spec
really shines. Say you wanted to test this bit of code:
(defn save-data! [data]
(try
(let [derived-data (xform data)]
(write! derived-data))
(catch Exception e
false)))
There are a number of things going on here, some of them out of our control. We would argue that unit tests for
this function should never cause xform
or write!
to actually be called, since they are not the logic
under test. Those functions should have their own tests that verify they work. Having dozens of failures
when a single thing is wrong is the "cascading failure" problem, and it can make your tests much less useful for
quickly figuring out exactly what got broken. (Of course, this kind of isolation isn’t very useful if you don’t
test the other pieces).
Another issue is that if we actually run write!
then it is very hard for us to control it…we can’t easily force
it to throw an exception so we can test that part of `save-data!’s logic.
When we want a true unit test for save-data!
we need to be able to take control of these other functions.
Fulcro spec includes macros that can help: provided
, when-mocking
, provided!
, and when-mocking!
. They all have
the same basic shape:
(deftest save-data!-test
(testing "Returns false on internal exceptions"
(provided "The write! throws an exception"
(write! d) =1x=> (throw (ex-info "" {:data-passed d}))
(save-data! {}) => false)))
The provided
macro takes 1 or more "arrow triples", and then any amount of code to run with mocks in place.
Each "arrow triple" does the following:
-
Verifies that the function is called the number of times specified in the arrow (e.g =10x⇒ means it MUST be called
exactly 10 times).
-
Captures arguments into the argument symbols (e.g. d
in the example above)
-
Makes the captured symbols available on the right-hand side (e.g. in this example we send it in the exception)
-
Evaluates the right-hand side (a single expression) as the mock, which can make further assertions (e.g. check captured args)
-
Verifies order (and number) of calls (order is only checked for function invocations of the same function)
Thus, you can spell out fairly complicated "scripts":
...
(provided "f is called 3+ times"
(f x) =1x=> (do
(assertions
"arg is even on the first call"
(even? x) => true)
42)
(f x) =1x=> (do
(assertions
"arg is odd on the second call"
(odd? x) => true)
44)
(f x) => 9
(g))
...
runs (g)
with f
mocked out. It implies that during the execution of g
that f
will be called at least 3 times (an arrow
without a number means "one or more times" and will capture the remaining calls). The first call’s parameter is checked
to see if it is even, and that mock then returns 42
. The second call checks for an odd argument and returns 44. The
final call(s) don’t check their args, and all return 9
.
The difference between provided
and when-mocking
is that the latter does not accept a descriptive string and does
not generate output, whereas the former does.
In Clojure(script) it is easy to fool yourself by making a mock that does impossible things and misleads you with
passing tests that make no logical sense in the real program.
The provided!
and when-mocking!
alternatives add an additional level of sanity checking: They will check that your
mocks are called with and return values that conform to the original function’s Clojure Spec. This is a very important
bit of glue. If your mock returns data that is impossible for the real function to ever return, then it is a clear sign that you
are forgetting how that function works, and will lead you to write a test that passes for very bad reasons:
(def f [a] (+ 1 a))
(def g [a]
(str a (f a))
(deftest g-test
(testing "g combines args with (f args)"
(when-mocking
(f a) => "22"
(g "Hi ") => "Hi 22")))
The above test passes, but it is clearly not right. f
Will never ever return a string, and the input of "Hi "
is
also an unacceptable thing to pass to +
inside of `f'. Now, when your functions are close together you might notice
this without any aid, but as soon as things get spread out then the chance of you making this kind of error becomes more probable, and
your tests become less trustworthy and useful.
Using the !
forms will look for (and enforce) the Clojure specs on the mocked functions (it does not instrument anything,
it literally checks the args and return values in the stubbed logic by looking up the spec on the original function):
;; Add this
(s/fdef f
:args (s/cat :a int?)
:ret int?)
(deftest g-test
(testing "g combines args with (f args)"
;; Change to using the `!` form
(when-mocking!
(f a) => "22"
(g "Hi ") => "Hi 22")))
With the above two changes you now get a failing test, and the error message will tell you when your mock either receives
an incorrect argument, or returns a non-conforming result.
The mocking system uses with-redefs
internally. Thus, anything that cannot be clearly redefined at runtime cannot be mocked. This
includes (but is not limited to):
-
Macros: Macros expand at compile time. There is no way to "circumvent them" at test runtime.
-
Inlined functions: An inlined function is replaced with it’s body at compile time.
-
Java artifacts: Java does not participate in Clojure’s dynamism. You must still use something like Mockito (or clojure
thin wrappers).
A workaround the works well for many things is to just wrap the problem call in a standard function.
This isn’t really a feature of the library, but is just a note about how to test macros in Clojure in general. Macros
are expanded at compile-time, so testing them in a runtime environment will not work. However, there is a very easy
workaround: Realize that a macro is nothing more than a special function that picks up unevaluated forms, and returns
a replacement for them. The fact that it runs at compile time is the inconvenient part.
The solution is quite simple: Write a true function that does the form maniputions, and call that from the macro.
You can then easily test that the macro does what you expect:
(defn the-macro* [form]
`(do
~form
(output-form "hello"))
(defmacro the-macro [form]
(the-macro* form))
...
(specification "The macro"
(behavior "does some stuff"
(assertions
(the-macro* '(f 2)) => `(clojure.core/do (ns-of-f/f 2) (other-ns/output-form "hello")))))