Generative testing in Clojure with test.check

The idea

test.check is a Clojure property-based testing tool inspired by QuickCheck from Haskell.

The core idea of test.check is that instead of enumerating expected input and output for unit tests, you write properties about your function that should hold true for all inputs.

This lets you write concise, powerful tests.

In a sense it gives you the illusion that you deal with the infinity.

Infinity

Code examples

First, let’s require test.check:


(ns my.test
  (:require [clojure.test.check :as tc]
            [clojure.test.check.generators :as gen]
            [clojure.test.check.properties :as prop :include-macros true]))

Let’s say we’re testing a sort function. We want to check that that our sort function is idempotent - that is, applying sort twice should be equivalent to applying it once: (= (sort a) (sort (sort a))).

Let’s write a quick test to make sure this is the case:

(def sort-idempotent-prop
  (prop/for-all [v (gen/vector gen/int)]
    (= (sort v) (sort (sort v)))))

(tc/quick-check 100 sort-idempotent-prop)

In prose, this test reads: for all vectors of integers, v, sorting v is equal to sorting v twice.

What happens if our test fails? test.check will try and find ‘smaller’ inputs that still fail. This process is called shrinking. Let’s see it in action:

(def prop-sorted-first-less-than-last
  (prop/for-all [v (gen/not-empty (gen/vector gen/int))]
    (let [s (sort v)]
      (< (first s) (last s)))))

(tc/quick-check 100 prop-sorted-first-less-than-last)

This test claims that the first element of a sorted vector should be less-than the last. Of course, this isn’t true: the test fails with input [3], which gets shrunk down to [0], as seen in the output above.

Deterministic Randomness

Each time you call tc/quick-check, test.check generates different test cases - as you can see in this two examples where the failing test cases are always different:

(:fail (tc/quick-check 100 prop-sorted-first-less-than-last))
(:fail (tc/quick-check 100 prop-sorted-first-less-than-last))

But what if you want to re-run exactly the same values?

No problem: pass :seed to tc/quick-check and you’ll run always the same values:

(:fail (tc/quick-check 100 prop-sorted-first-less-than-last :seed 1477508406394))
(:fail (tc/quick-check 100 prop-sorted-first-less-than-last :seed 1477508406394))

Shrinking

As your test functions require more sophisticated input, shrinking becomes critical to being able to understand exactly why a random test failed. To see how powerful shrinking is, let’s come up with a contrived example: a function that fails if it’s passed a sequence that contains the number 12:

(def prop-no-12
  (prop/for-all [v (gen/vector gen/int)]
    (not (some #{12} v))))

(tc/quick-check 100 prop-no-12)

We see that the test failed on a rather large vector, as seen in the :fail key. But then test.check was able to shrink the input down to [12], as seen in the keys [:shrunk :smallest].

zipmap

(zipmap keys vals) allows you to creates a map with the keys mapped to the corresponding vals.

(keys map) retrieves the keys of a map.

(vals map) retrieves the values of a map.

But how well do they combine together?

According to keys and vals docstrings, the keys and the values are returned in the same order - the order of (seq map)

And indeed, for map with 100 pairs of random integers, zipmap, keys and vals are consistent:

(def n 100)
(def mm (zipmap (shuffle (range n)) (shuffle (range n))))
[(count mm) (= mm (zipmap (keys mm) (vals mm)))]

You can try to increase the value of n - and it will remain true. But is it a proof? What about keys and values from other types?

Let’s check it for sure - using a more advanced random genertor, provided by test.check:

(def random-map (gen/map (gen/one-of [gen/keyword gen/string gen/boolean gen/int gen/symbol]) gen/int))

(gen/one-of generators) creates a generator that randomly chooses a value from the list of generators.

(gen/map map key-gen val-gen) creates a generator that generates maps, with keys chosen from key-gen and values chosen from val-gen.

Let’s look at some samples - with gen/sample:

(gen/sample random-map)

Now, we can test the consistency of zipmap, keys and vals:

(def zipmap-keys-vals-consistency
  (prop/for-all [m (gen/map (gen/one-of [gen/keyword gen/string gen/boolean gen/int gen/symbol]) gen/int)]
                  (= m (zipmap (keys m) (vals m)))))

(tc/quick-check 15 zipmap-keys-vals-consistency)

It seems that the docstrings were right: zipmap, keys and vals are consistent.

Check test.check documentation for additional functions and explanations.

Conclusion

I hope that you enjoyed this interactive tutorial about generative testing in clojure. This is really a powerful paradigm that might change forever the way you write and think about testing. And who know? Maybe it will catch a bug or two…

Please forward it to your friends if you liked it and share your critics on twitter @viebel or on slack #klipse channel.