~ / track C / clojure ecosystem
Generative testing with test.check
IntermediateMost 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.