Generative Testing with test.check in Clojure

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 integers

Generate 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" ...)

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 1000

Or 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/let and gen/fmap for domain-specific generators
  • Automatic shrinking to find the minimal failing case
  • defspec for 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.

Read more