Generative Testing with test.check in Clojure
Example-based tests check specific cases. Property-based tests check invariants across hundreds of randomly generated inputs. test.check is Clojure's property testing library — a port of Haskell's QuickCheck — and it finds bugs that no developer thought to test for.
Why Property-Based Testing
Consider a sorting function. Example-based tests:
(deftest sort-test
(is (= [1 2 3] (my-sort [3 1 2])))
(is (= [] (my-sort [])))
(is (= [1] (my-sort [1]))))These pass for my-sort = identity if the input was already sorted. They test the specific examples the developer thought of. Property-based tests describe what's always true:
(defspec sort-properties 100
(prop/for-all [v (gen/vector gen/int)]
(let [sorted (my-sort v)]
(and
(= (count v) (count sorted)) ;; same length
(= (sort v) sorted) ;; same elements, sorted
(every? #(<= (first %) (second %)) ;; each pair ordered
(partition 2 1 sorted))))))This runs 100 random vectors and checks all three properties for each.
Installation
;; deps.edn
{:deps {org.clojure/clojure {:mvn/version "1.11.1"}
org.clojure/test.check {:mvn/version "1.1.1"}}}test.check integrates with clojure.test via defspec:
(ns myapp.core-test
(:require [clojure.test :refer [deftest is]]
[clojure.test.check :as tc]
[clojure.test.check.generators :as gen]
[clojure.test.check.properties :as prop]
[clojure.test.check.clojure-test :refer [defspec]]))Built-in Generators
test.check ships with generators for common types:
;; Scalar generators
gen/int ;; integers (positive and negative)
gen/nat ;; natural numbers (0 and up)
gen/pos-int ;; positive integers
gen/large-integer ;; large integers including Long/MIN_VALUE
gen/double ;; doubles including NaN, Infinity
gen/boolean ;; true or false
gen/char ;; characters
gen/string ;; strings
gen/string-ascii ;; ASCII-only strings
gen/symbol ;; symbols
gen/keyword ;; keywords
gen/uuid ;; UUIDs
;; Collection generators
(gen/vector gen/int) ;; vectors of integers
(gen/list gen/string) ;; lists of strings
(gen/set gen/keyword) ;; sets of keywords
(gen/map gen/keyword gen/int) ;; maps with keyword keys and int values
;; Sized generators
(gen/vector gen/int 5) ;; exactly 5 integers
(gen/vector gen/int 1 10) ;; 1 to 10 integersGenerate sample values to inspect:
(gen/sample gen/int)
;; => (0 1 -1 -2 1 -3 -2 3 3 -6)
(gen/sample (gen/vector gen/int 0 5))
;; => ([] [0] [1 -1] [] [-2 2 -1] ...)Writing Properties
Properties are functions that return true/false for each generated input. Use prop/for-all to bind generated values:
;; Commutativity: a + b = b + a
(defspec addition-commutative 500
(prop/for-all [a gen/int
b gen/int]
(= (+ a b) (+ b a))))
;; Idempotency: sorting twice = sorting once
(defspec sort-idempotent 100
(prop/for-all [v (gen/vector gen/int)]
(= (sort v) (sort (sort v)))))
;; Round-trip: encode then decode returns original
(defspec json-roundtrip 200
(prop/for-all [m (gen/map gen/keyword
(gen/one-of [gen/string gen/int gen/boolean]))]
(= m (json/parse-string (json/generate-string m) true))))Custom Generators
Real code operates on domain objects, not raw integers. Build custom generators with gen/fmap, gen/let, and gen/bind:
Using gen/fmap
Transform generated values:
;; Generator for email addresses
(def email-gen
(gen/fmap (fn [[username domain tld]]
(str username "@" domain "." tld))
(gen/tuple gen/string-alphanumeric
gen/string-alphanumeric
(gen/elements ["com" "org" "net" "io"]))))
(gen/sample email-gen)
;; => ("@." "@a.com" "b@c.org" ...)Using gen/let (recommended)
gen/let provides a cleaner syntax for composing generators:
;; Generator for a user map
(def user-gen
(gen/let [id gen/uuid
name gen/string-alphanumeric
age (gen/choose 18 120)
email email-gen
active? gen/boolean]
{:id id
:name name
:age age
:email email
:active? active?}))
(gen/sample user-gen 3)
;; => ({:id #uuid "...", :name "abc", :age 42, ...} ...)Recursive generators
For tree-like data structures, use gen/recursive-gen:
;; Generator for nested JSON-like data
(def json-gen
(gen/recursive-gen
(fn [inner-gen]
(gen/one-of
[(gen/map gen/string-alphanumeric inner-gen)
(gen/vector inner-gen)]))
(gen/one-of [gen/string gen/int gen/boolean (gen/return nil)])))Dependent generators
When one value depends on another:
;; Generate a start date and an end date that's always after it
(def date-range-gen
(gen/let [start (gen/choose 0 1000000)
duration (gen/choose 1 10000)]
{:start start
:end (+ start duration)}))
(defspec date-range-valid 100
(prop/for-all [{:keys [start end]} date-range-gen]
(> end start)))Shrinking
When test.check finds a failing input, it automatically shrinks it to the smallest failing case. This is one of the most valuable features.
;; A buggy function
(defn buggy-sum [coll]
(if (> (count coll) 3)
(throw (ex-info "Too many items!" {:count (count coll)}))
(apply + coll)))
(defspec buggy-sum-test 100
(prop/for-all [v (gen/vector gen/int)]
(= (apply + v) (buggy-sum v))))Without shrinking, the failure might show a vector like [3 -1 5 2 8 -4 0 7 -2 1]. After shrinking, test.check reports the minimal failing case: [0 0 0 0] — four elements, the smallest vector that triggers the bug.
Shrinking is automatic for all built-in generators. Custom generators using gen/fmap and gen/let inherit shrinking from their component generators.
Integration with clojure.test
defspec integrates with clojure.test:
;; Run with clojure -M:test or lein test
(defspec my-property 100
(prop/for-all [x gen/int]
(= x (identity x))))Control the number of tests at the call site:
# Run with more tests in CI
clojure -M:<span class="hljs-built_in">test :num-tests 1000Or programmatically:
(tc/quick-check 1000 my-property)
;; => {:result true, :num-tests 1000, :seed 12345}Real-World Example: Testing a Parser
;; A simple expression parser
(defn parse-expr [s] ...)
(defn render-expr [expr] ...)
;; Generator for valid expressions
(def expr-gen
(gen/recursive-gen
(fn [inner]
(gen/one-of
[(gen/fmap (fn [[op l r]] {:op op :left l :right r})
(gen/tuple (gen/elements [:+ :- :* :/])
inner
inner))]))
(gen/fmap (fn [n] {:type :number :value n})
gen/int)))
;; Property: parse(render(expr)) = expr
(defspec parser-roundtrip 500
(prop/for-all [expr expr-gen]
(= expr (parse-expr (render-expr expr)))))
;; Property: rendered form is a valid string
(defspec parser-renders-string 500
(prop/for-all [expr expr-gen]
(string? (render-expr expr))))Testing Stateful Systems
For stateful systems (databases, queues), use gen/commands with the stateful-check approach. The simpler manual approach uses sequences of operations:
;; Test an in-memory key-value store
(def operation-gen
(gen/one-of
[(gen/fmap (fn [[k v]] [:put k v])
(gen/tuple gen/keyword gen/int))
(gen/fmap (fn [k] [:get k])
gen/keyword)
(gen/fmap (fn [k] [:delete k])
gen/keyword)]))
(defspec kv-store-consistency 100
(prop/for-all [ops (gen/vector operation-gen 1 50)]
;; Run same operations on our store and a reference map
(let [store (atom {})
model (atom {})
results
(mapv (fn [[op & args]]
(case op
:put (do (swap! store put! (first args) (second args))
(swap! model assoc (first args) (second args))
:ok)
:get [(get @store (first args))
(get @model (first args))]
:delete (do (swap! store delete! (first args))
(swap! model dissoc (first args))
:ok)))
ops)]
;; After all operations, store and model should match
(= @store @model))))Spec Integration
When using clojure.spec, generate from specs directly with spec/gen:
(require '[clojure.spec.alpha :as s]
'[clojure.spec.gen.alpha :as sgen])
(s/def ::age (s/and int? #(>= % 0) #(<= % 150)))
(s/def ::name (s/and string? #(> (count %) 0)))
(s/def ::user (s/keys :req [::name ::age]))
;; Generate from spec
(gen/sample (sgen/gen (s/get-spec ::user)))
;; Property: any valid user passes validation
(defspec spec-validates-generated 100
(prop/for-all [user (sgen/gen (s/get-spec ::user))]
(s/valid? ::user user)))Finding the Right Properties
The hardest part of property-based testing is identifying properties to test. Common patterns:
Round-trip — encode/decode, serialize/deserialize, compress/decompress
Idempotency — f(f(x)) = f(x) for operations like sorting, normalizing, deduplication
Inverse operations — insert then delete returns original state
Oracle — compare optimized function against naive reference implementation
Model-based — compare system under test against a simpler model (as shown with the KV store above)
Invariants — sorted output is always sorted, non-negative lengths, etc.
Summary
test.check brings property-based testing to Clojure with:
- Built-in generators for all primitive types and collections
gen/letandgen/fmapfor domain-specific generators- Automatic shrinking to find the minimal failing case
defspecfor clojure.test integration- Spec integration for generating from existing specs
The payoff is finding entire classes of bugs — off-by-one errors, missing edge cases, incorrect assumptions about input — that example-based tests would never catch. Start with one or two properties for your most critical functions and expand from there.