Property-Based Testing in Clojure with test.check

Property-Based Testing in Clojure with test.check

Example-based tests verify that specific inputs produce specific outputs. They are good at documenting intended behavior and catching known regressions. What they cannot do is find the bugs you haven't thought of yet. Property-based testing flips the model: you describe invariants your code must always satisfy, and the framework generates hundreds of random inputs to find violations. In Clojure, test.check is the standard library for this approach.

What Is Property-Based Testing?

Instead of writing:

(deftest sort-test
  (is (= [1 2 3] (sort [3 1 2])))
  (is (= [1 2 3] (sort [1 2 3])))
  (is (= [] (sort []))))

You write properties that hold for all valid inputs:

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

(defspec sort-preserves-count 100
  (prop/for-all [v (gen/vector gen/int)]
    (= (count v) (count (sort v)))))

(defspec sort-produces-ordered-output 100
  (prop/for-all [v (gen/vector gen/int)]
    (every? true?
            (map <= (sort v) (rest (sort v))))))

The 100 argument tells test.check how many random examples to generate. Each property is tested against 100 randomly generated vectors. When a violation is found, test.check automatically shrinks the failing input to the smallest example that still fails.

Setting Up test.check

Add to deps.edn:

{:deps {org.clojure/clojure {:mvn/version "1.11.1"}
        org.clojure/test.check {:mvn/version "1.1.1"}}}

The main namespaces you'll use:

(ns myapp.property-test
  (:require [clojure.test :refer :all]
            [clojure.test.check :as tc]
            [clojure.test.check.generators :as gen]
            [clojure.test.check.properties :as prop]
            [clojure.test.check.clojure-test :refer [defspec]]))

Core Generators

Primitive Generators

;; Integers
gen/int          ;; any int (positive or negative)
gen/nat          ;; non-negative int
gen/pos-int      ;; positive int (> 0)
gen/large-integer ;; any Java long

;; Floats
gen/double       ;; any double including NaN and Infinity
(gen/double* {:infinite? false :NaN? false :min 0.0 :max 100.0})

;; Strings and chars
gen/string       ;; any string
gen/string-alphanumeric  ;; [a-zA-Z0-9] strings
gen/char
gen/char-alphanumeric

;; Booleans
gen/boolean

;; Keywords and symbols
gen/keyword
gen/symbol

Collection Generators

;; Vector of up to 10 random ints
(gen/vector gen/int)

;; Vector with size bounds
(gen/vector gen/int 3 7)     ;; between 3 and 7 elements
(gen/vector gen/int 5)       ;; exactly 5 elements

;; List, set, map
(gen/list gen/string-alphanumeric)
(gen/set gen/keyword)
(gen/map gen/keyword gen/int)

;; Non-empty collections
(gen/not-empty (gen/vector gen/int))

;; Nested structures
(gen/vector (gen/map gen/keyword gen/string) 1 5)

Choosing and Combining

;; Pick one from a list
(gen/elements [:circle :square :triangle])

;; Pick one generator from a list (equal probability)
(gen/one-of [gen/int gen/string gen/boolean])

;; Pick with weighted frequency
(gen/frequency [[5 gen/int]    ;; 5/7 of the time
                [2 gen/string]]) ;; 2/7 of the time

;; Tuple: fixed-length vector of different types
(gen/tuple gen/string gen/int gen/boolean)

gen/such-that: Filtering Generators

gen/such-that filters a generator to only produce values that satisfy a predicate:

;; Only positive even numbers
(def gen-pos-even
  (gen/such-that even? gen/pos-int))

;; Non-empty strings
(def gen-non-empty-string
  (gen/such-that not-empty gen/string-alphanumeric))

;; Maps with at least one key
(def gen-non-empty-map
  (gen/such-that not-empty (gen/map gen/keyword gen/int)))

Be careful with gen/such-that when the predicate is very selective. If too many values are rejected, test.check will give up with a "couldn't generate a value" error. In those cases, use gen/fmap to transform values instead:

;; FRAGILE: gen/such-that with low probability predicate
(gen/such-that #(> % 1000000) gen/nat)  ; generates many rejects

;; BETTER: transform to get what you want
(gen/fmap #(+ 1000000 %) gen/nat)

gen/fmap: Transforming Generator Output

gen/fmap maps a function over a generator's output:

;; Generate even numbers
(def gen-even
  (gen/fmap #(* 2 %) gen/int))

;; Generate strings that start with "user-"
(def gen-username
  (gen/fmap #(str "user-" %) gen/string-alphanumeric))

;; Generate sorted vectors
(def gen-sorted-vec
  (gen/fmap sort (gen/vector gen/int)))

;; Generate maps with specific structure
(def gen-point
  (gen/fmap (fn [[x y]] {:x x :y y})
            (gen/tuple gen/double gen/double)))

gen/bind: Dependent Generators

When the shape of one generator depends on the output of another, use gen/bind:

;; Generate a vector and then a valid index into it
(def gen-vec-and-index
  (gen/bind (gen/not-empty (gen/vector gen/int))
            (fn [v]
              (gen/tuple (gen/return v)
                         (gen/choose 0 (dec (count v)))))))

(defspec nth-in-bounds 100
  (prop/for-all [[v idx] gen-vec-and-index]
    (some? (nth v idx))))

gen/bind is Clojure's equivalent of monadic bind for generators. The function receives a generated value and returns a new generator. This enables dependent data generation where validity constraints span multiple values.

Custom Generators for Domain Types

This is where property-based testing becomes powerful. Once you have generators for your domain types, properties compose naturally.

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

;; Email generator
(def gen-email
  (gen/fmap (fn [[user domain tld]]
              (str user "@" domain "." tld))
            (gen/tuple
              (gen/not-empty gen/string-alphanumeric)
              (gen/not-empty gen/string-alphanumeric)
              (gen/elements ["com" "org" "net" "io"]))))

;; User generator
(def gen-user
  (gen/hash-map
    :id     (gen/fmap str (gen/uuid))
    :name   (gen/not-empty gen/string-alphanumeric)
    :email  gen-email
    :age    (gen/choose 18 120)
    :role   (gen/elements [:admin :user :moderator])))

;; Address generator
(def gen-address
  (gen/hash-map
    :street  (gen/not-empty gen/string-alphanumeric)
    :city    (gen/not-empty gen/string-alphanumeric)
    :country (gen/elements ["US" "UK" "DE" "FR" "JP"])
    :zip     (gen/fmap (partial format "%05d") (gen/choose 10000 99999))))

;; Order generator — depends on user
(defn gen-order-for-user [user-id]
  (gen/hash-map
    :id          (gen/fmap str (gen/uuid))
    :user-id     (gen/return user-id)
    :items       (gen/not-empty
                   (gen/vector
                     (gen/hash-map
                       :product-id (gen/fmap str (gen/uuid))
                       :quantity   (gen/choose 1 10)
                       :price      (gen/fmap #(/ % 100.0) (gen/choose 100 10000)))))
    :status      (gen/elements [:pending :processing :shipped :delivered])))

Now write properties against your domain model:

(defspec user-serialization-roundtrip 200
  (prop/for-all [user gen-user]
    (= user (-> user
                (cheshire.core/generate-string)
                (cheshire.core/parse-string true)
                (update :role keyword)))))

(defspec order-total-always-positive 100
  (prop/for-all [order (gen/bind gen-user
                                  #(gen-order-for-user (:id %)))]
    (pos? (calculate-total order))))

defspec and Running Properties

defspec integrates test.check properties with clojure.test:

;; Basic defspec
(defspec my-property 100
  (prop/for-all [n gen/pos-int]
    (pos? n)))

;; defspec with options
(defspec my-property-with-seed
  {:num-tests 500
   :seed 12345}      ;; reproducible runs
  (prop/for-all [s gen/string]
    (= (count s) (count (reverse s)))))

You can also run properties directly without defspec:

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

;; Run directly
(tc/quick-check 100 my-prop)
;; => {:result true, :pass? true, :num-tests 100, :seed 1234567890}

;; With specific seed for reproduction
(tc/quick-check 100 my-prop {:seed 42})

Understanding Shrinking

Shrinking is the killer feature of property-based testing. When test.check finds a failing case, it automatically tries to find the smallest input that still fails.

;; This property is false (sort doesn't reverse)
(defspec sort-equals-reverse 100
  (prop/for-all [v (gen/vector gen/int)]
    (= (sort v) (reverse v))))

Without shrinking, you might get a failing case like [5 -3 8 2 -1 9 4]. With shrinking, test.check will find the minimal counterexample, likely something like [1 0] or [0 -1].

The shrinking output looks like:

FAIL in (sort-equals-reverse)
Falsified after 3 tests.
Shrunk 12 times to:
{:smallest [[0 -1]], :fail [[0 -1]]}

Built-in generators shrink automatically. Custom generators built with gen/fmap, gen/bind, and gen/tuple also shrink because they compose shrinkable generators.

For gen/such-that, shrinking still works but may be less effective if many intermediate values are filtered out.

Integrating with clojure.test

defspec tests run when you run your normal test suite:

clojure -M:test

The output integrates with standard clojure.test output:

Testing myapp.property-test

Ran 5 tests containing 500 assertions.
0 failures, 0 errors.

For CI, set the number of tests via a system property or environment variable to run fewer checks locally and more in CI:

(def num-tests
  (or (some-> (System/getenv "TEST_CHECK_FACTOR")
              Long/parseLong
              (* 100))
      100))

(defspec my-property num-tests
  (prop/for-all [n gen/int]
    ...))

Practical Property Patterns

Roundtrip Properties

Encode-decode roundtrips are the most common and valuable properties:

(defspec json-roundtrip 200
  (prop/for-all [m (gen/map gen/keyword
                            (gen/one-of [gen/int gen/string gen/boolean]))]
    (= m (-> m
             json/write-str
             (json/read-str :key-fn keyword)))))

(defspec transit-roundtrip 200
  (prop/for-all [data (gen/one-of [gen/int gen/string
                                   (gen/vector gen/int)
                                   (gen/map gen/keyword gen/int)])]
    (let [out (java.io.ByteArrayOutputStream.)
          writer (transit/writer out :json)]
      (transit/write writer data)
      (let [in (java.io.ByteArrayInputStream. (.toByteArray out))
            reader (transit/reader in :json)]
        (= data (transit/read reader))))))

Invariant Properties

Properties that must hold regardless of input:

;; set operations
(defspec union-is-commutative 100
  (prop/for-all [a (gen/set gen/int)
                 b (gen/set gen/int)]
    (= (clojure.set/union a b)
       (clojure.set/union b a))))

(defspec intersection-is-subset-of-both 100
  (prop/for-all [a (gen/set gen/int)
                 b (gen/set gen/int)]
    (let [i (clojure.set/intersection a b)]
      (and (clojure.set/subset? i a)
           (clojure.set/subset? i b)))))

Oracle Properties

Compare a fast implementation against a slow but obviously correct one:

(defn naive-median [coll]
  (let [sorted (sort coll)
        n (count sorted)
        mid (quot n 2)]
    (if (odd? n)
      (nth sorted mid)
      (/ (+ (nth sorted (dec mid))
            (nth sorted mid))
         2.0))))

(defspec optimized-median-matches-naive 200
  (prop/for-all [v (gen/not-empty (gen/vector gen/int))]
    (= (naive-median v) (fast-median v))))

Stateful Property Testing

For stateful systems, model-based testing defines an abstract model alongside the implementation:

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

;; Define commands
(defn make-add-command []
  {:command :add
   :args    (gen/tuple gen/int)
   :next-state (fn [state [n]] (conj state n))
   :postcondition (fn [_prev-state curr-state result [n]]
                    (contains? curr-state n))})

(defn make-remove-command [state]
  (when (not-empty state)
    {:command :remove
     :args    (gen/elements (vec state))
     :next-state (fn [state [n]] (disj state n))
     :postcondition (fn [_prev curr result [n]]
                      (not (contains? curr n)))}))

Common Pitfalls

Generating invalid states: Use gen/such-that carefully; prefer gen/fmap transforms.

Properties that always pass: A property like (pos? (Math/abs n)) fails for Long/MIN_VALUE — be precise about your domain.

Slow generators: gen/bind chains can be slow. Profile your generators with (time (gen/generate your-gen)).

Missing edge cases: Explicitly test boundaries by including gen/return of known edge cases:

(def gen-int-with-edges
  (gen/one-of [(gen/return 0)
               (gen/return Long/MAX_VALUE)
               (gen/return Long/MIN_VALUE)
               gen/int]))

Property-based testing and example-based testing are complementary. Use deftest/is for documented behavior and regression cases; use defspec and prop/for-all to explore the space of all possible inputs and discover unexpected failures. The combination gives you both confidence and coverage that neither approach achieves alone.

Read more

Start now free