Example-based Unit Testing in Clojure
Summary: Unit testing in Clojure is straightforward. Here are a few testing ideas as they apply to Clojure.
Most of the Unit Testing literature discusses how to unit test Object Oriented code. However, Unit Testing is very useful for functional code, including Clojure. I'd like to document a few unit testing ideas as they relate to Clojure.
The Unit you're testing is the function
Unit testing is about testing the smallest unit of code. In Object Oriented languages, the unit is the class. In functional languages, the unit is typically the function. This is true in Clojure. If you're testing individual functions, you're unit testing Clojure.
Example-based tests
The easiest kind of tests to do is example-based, which means you test that for a given argument, you get a known return value. Let's look at a simple example:
(deftest addition-tests
(is (= 5 (+ 3 2)))
(is (= 10 (+ 5 5))))
You're testing that +
works on two different inputs! Notice: 2 lines
and 2 inputs, it looks like we'll get linear growth in tests as our
coverage increases.
Round-trip testing
Ok, it's not exactly unit testing, if you are strictly going by the definition of "unit", because you're actually testing two functions. But who's really being so strict? A really useful kind of test is the round-trip test.
In Clojure, pr-str
prints a value readably, meaning if the value can
be read back in, this will make a string that could be read in using
clojure.edn/read-string
. You can do a round-trip from value to string
back to value, and the two values should be equal. You're testing the
property that these two functions are inverses of each other.
Example:
(deftest pr-str-read-string-round-trip
(is (= [1 2 3] (read-string (pr-str [1 2 3]))))
(is (= {:a :b :c :d} (read-string (pr-str {:a :b :c :d})))))
Again, we're getting linear test growth.
Here's another example where I test that addition is the opposite of subtraction:
(deftest +---round-trip
(is (= 5 (-> 5 (+ 10) (- 10))))
(is (= 10 (-> 10 (+ 100) (- 100)))))
What examples to test
If you're writing example-based tests one-by-one, and you're getting linear benefit for your examples, you've really got to maximize what you test, because linear growth is actually quite bad. In that case, what do you test? The best thing to test are the corner cases. Corner cases are mostly domain-dependent, but there are some domain-independent ones.
Empty collections
It's good to test what happens when you call a function on an empty collection. It could be that you didn't handle that, or didn't handle it correctly. The biggest gotcha is stuff like dividing by the size of the collection. If it's empty, the size is zero, and that's undefined.
Empty strings
The bane of the web programmer's existence, empty strings are usually not valid input, but of course that doesn't stop someone from passing one in. Are you testing that it's valid?
Zero
Zero is actually a typical corner case. Try it out.
One
One is also a typical corner case.
Normal cases
You should have at least one normal case to test the expected behavior. A normal case is a list with 5 elements, or a small integer (7, 12, 34).
Bugs
Now, here's the thing that makes having a test system set up totally worth it: having a place to put tests for known and fixed bugs. If someone reports a bug, it's really nice to reproduce it in code in a failing test before you fix it. If the bug happened once, it could happen again, so make it part of your anti-regression suite.
Multiple assertions on the return value
One last thing that happens in Clojure is you want to assert a few things about the same return value. Instead of calling the function several times, why not save the value and assert a few things about it?
(deftest map-test
(let [return (map - [1 2 3 4 5 6 7])]
(is (= 7 (count return)))
(is (every? neg? return))))
One step further
Ok, I've mentioned a few times that example-based testing does not scale. Code coverage grows linearly as the number of examples grows. How do you get better than linear? One way is to use property-based testing (also known as generative testing). Instead of the programmer giving examples, the program generates examples! Here's a preview:
(defspec edn-roundtrips 50
(prop/for-all [a gen/any]
(= a (-> a prn-str edn/read-string))))
This tests that any value (gen/any
) can be printed to a string and
read back in, and you get an equivalent value. Three lines. You can run
this with as many randomly generated values as you'd like. Thousands.
Millions. With three lines. That's leverage.
Conclusion
Ok, those are just a few ideas that could get you started with
example-based unit testing in Clojure. If you'd like to start
automated testing in Clojure, I suggest you check out LispCast Intro to
clojure.test.
clojure.test
is the built-in, standard Clojure testing library that
most systems use (or are compatible with). The LispCast course is an
interactive course with animation, examples, screencasts, text, code
samples, and more!
Now, if you'd like to up your game at testing, and want to get more than linear bang for your buck, you've got to get into generative testing.