~ / track C / clojure ecosystem

Generative testing with test.check

Intermediate

Most tests check specific examples. Property-based tests check laws that should hold for all inputs, by generating hundreds of random ones. When a property fails, test.check automatically shrinks the counterexample to the simplest input that still triggers the failure — and that minimized counterexample is often more useful than a stack trace.

Note: examples below use org.clojure/test.check. The SCI REPL does not include it; run in a real Clojure project.

Generators produce typed random data

gen/sample shows what a generator can produce — useful for getting a feel before writing a property:

(require '[clojure.test.check.generators :as gen])

(gen/sample gen/small-integer 5)
;; => (0 -1 1 0 -2)

(gen/sample (gen/vector gen/small-integer) 5)
;; => ([] [0] [-1 1] [] [0 1 -2])

Generators compose: (gen/tuple a b), (gen/hash-map :k v ...), (gen/such-that pred g), (gen/let [...] ...), etc.

Writing a property

A property says "for all inputs of this shape, this predicate holds." Here we check that reversing a vector twice gives back the original:

(require '[clojure.test.check :as tc]
         '[clojure.test.check.properties :as prop])

(def reverse-roundtrip
  (prop/for-all [v (gen/vector gen/any-equatable)]
    (= v (reverse (reverse v)))))

(tc/quick-check 200 reverse-roundtrip)
;; => {:result true :num-tests 200 :seed ...}

for-all binds v to a freshly generated vector for each of the 200 trials. The property body returns truthy when the law holds; falsy or thrown means the property is broken.

Shrinking: the killer feature

When a property fails, test.check doesn't just report "200 tests, broke on trial 47 with [42 -19 7 ...]." It shrinks that counterexample by trying simpler variants until none of them still fail. You see the minimal input that breaks your invariant:

(def sort-is-idempotent-bad
  (prop/for-all [xs (gen/vector gen/small-integer)]
    (= (sort xs) (sort (sort (rest xs))))))  ;; deliberately wrong

(tc/quick-check 100 sort-is-idempotent-bad)
;; => {:result false ...
;;     :shrunk {:smallest [[0 -1]] ...}}

The smallest broken input is [0 -1] — small enough to debug at a glance.

Combining with clojure.test

defspec ties a property into the regular clojure.test runner so it shows up in CI alongside example-based tests:

(require '[clojure.test.check.clojure-test :refer [defspec]])

(defspec reverse-twice-is-id 200
  (prop/for-all [v (gen/vector gen/small-integer)]
    (= v (reverse (reverse v)))))

When generative tests pay off

  • Functions on collections (sort, dedup, merge, conj, etc.) — many laws.
  • Round-trip pairs (encode/decode, serialize/parse) — (= x (decode (encode x))).
  • Algebraic properties (associativity, commutativity, identity).
  • Pure transformations whose preconditions can be captured in a generator.

For one-off, scenario-specific tests (e.g. "this exact billing case"), an example test is still the right tool. Use both.

Check yourself

? quiz

Why is `test.check`'s shrinking step so important in practice?

Exercise

Write a property stating that (into [] (concat a b)) has length (+ (count a) (count b)) for all vectors of integers a and b. Run it with tc/quick-check 200. If the property holds, change concat to (rest (concat a b)) and observe what test.check reports.

 status: new