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.