clojure.test Advanced Patterns: Fixtures, Custom Matchers, and Test Hierarchies
Most Clojure developers learn deftest and is quickly, then stop. They write tests that look fine but miss the idioms that make large test suites maintainable, fast, and expressive. This post covers the patterns that separate workable test suites from great ones — fixtures that actually clean up, custom matchers that produce readable failures, and namespace structures that scale.
Beyond Basic deftest
The simplest clojure.test looks like this:
(ns myapp.core-test
(:require [clojure.test :refer :all]
[myapp.core :refer :all]))
(deftest addition-test
(is (= 4 (add 2 2))))This works for trivial cases. The moment you need shared state, setup/teardown, or domain-specific assertions, you hit walls. Let's break through them.
use-fixtures: The Right Way to Manage State
Fixtures in clojure.test come in two modes: :once (wraps the entire namespace's tests) and :each (wraps each individual test). Understanding when to use each prevents leaky state and slow suites.
:once Fixtures
Use :once for expensive setup that is read-only during tests — starting a server, loading a large dataset, establishing a connection pool:
(ns myapp.db-test
(:require [clojure.test :refer :all]
[myapp.db :as db]
[next.jdbc :as jdbc]))
(def ^:dynamic *db* nil)
(defn with-database [test-fn]
(let [conn (jdbc/get-connection
{:dbtype "h2:mem"
:dbname "testdb"
:DB_CLOSE_DELAY "-1"})]
(db/run-migrations! conn)
(binding [*db* conn]
(test-fn))
(.close conn)))
(use-fixtures :once with-database)
(deftest user-creation-test
(let [user (db/create-user! *db* {:name "Alice" :email "alice@example.com"})]
(is (some? (:id user)))
(is (= "Alice" (:name user)))))The fixture function takes test-fn as its argument and is responsible for calling it. Anything before the call is setup; anything after is teardown. This makes cleanup explicit and impossible to forget.
:each Fixtures
Use :each for state that must be reset between tests — database rows, mutable atoms, temporary files:
(ns myapp.cart-test
(:require [clojure.test :refer :all]
[myapp.cart :as cart]))
(def ^:dynamic *cart* nil)
(defn fresh-cart [test-fn]
(binding [*cart* (cart/new-cart)]
(test-fn)))
(use-fixtures :each fresh-cart)
(deftest add-item-test
(cart/add-item! *cart* {:id 1 :name "Widget" :price 9.99})
(is (= 1 (cart/item-count *cart*))))
(deftest remove-item-test
(cart/add-item! *cart* {:id 1 :name "Widget" :price 9.99})
(cart/remove-item! *cart* 1)
(is (= 0 (cart/item-count *cart*))))Each test starts with a clean cart regardless of what the previous test did.
Composing Multiple Fixtures
You can stack fixtures. They wrap in the order declared:
(defn with-logging [test-fn]
(println "Starting test at" (java.time.Instant/now))
(test-fn)
(println "Finished test at" (java.time.Instant/now)))
(defn with-transaction [test-fn]
(jdbc/with-transaction [tx *db* {:rollback-only true}]
(binding [*db* tx]
(test-fn))))
;; with-database wraps with-logging which wraps with-transaction
(use-fixtures :once with-database)
(use-fixtures :each with-logging with-transaction)The transaction fixture uses rollback-only mode — every test runs inside a transaction that gets rolled back, leaving the database pristine without any explicit delete logic.
Custom Assertion Functions with are
The are macro is underused. It lets you express multiple related assertions with a template:
(deftest string-operations-test
(are [input expected]
(= expected (clojure.string/upper-case input))
"hello" "HELLO"
"world" "WORLD"
"clojure" "CLOJURE"
"" ""))The first vector names the variables. The expression that follows uses those variables. Each subsequent pair of values provides one test case. This is dramatically cleaner than copy-pasted is calls.
Building Domain-Specific Assertions
For repeated assertion patterns across your test suite, define custom assertion functions using is internally:
(defn assert-valid-user [user]
(is (map? user) "User should be a map")
(is (uuid? (:id user)) "User ID should be a UUID")
(is (string? (:name user)) "User name should be a string")
(is (re-matches #".+@.+\..+" (:email user)) "User email should be valid")
(is (inst? (:created-at user)) "User created-at should be an instant"))
(deftest create-user-returns-valid-user
(let [user (create-user {:name "Bob" :email "bob@example.com"})]
(assert-valid-user user)
(is (= "Bob" (:name user)))))When assert-valid-user fails, clojure.test reports the specific failing assertion with its message. The failure output is clear about what was wrong.
Custom Predicates for is
You can wrap predicates to produce better failure messages using the is macro's second argument:
(defmacro is-sorted [coll]
`(is (= (sort ~coll) ~coll)
(str "Expected collection to be sorted, got: " ~coll)))
(defmacro is-subset [sub super]
`(is (clojure.set/subset? ~sub ~super)
(str ~sub " is not a subset of " ~super)))
(deftest sorting-test
(is-sorted [1 2 3 4 5])
(is-subset #{:a :b} #{:a :b :c :d}))Testing Multimethods
Multimethods require testing the dispatch function separately from the implementations:
(ns myapp.shapes
(:require [clojure.test :refer :all]))
(defmulti area :shape)
(defmethod area :circle [{:keys [radius]}]
(* Math/PI radius radius))
(defmethod area :rectangle [{:keys [width height]}]
(* width height))
(defmethod area :triangle [{:keys [base height]}]
(* 0.5 base height))
;; In tests:
(deftest multimethod-dispatch-test
;; Test each dispatch value
(testing "circle area"
(is (< (Math/abs (- (* Math/PI 25) (area {:shape :circle :radius 5})))
0.0001)))
(testing "rectangle area"
(is (= 12 (area {:shape :rectangle :width 3 :height 4}))))
(testing "triangle area"
(is (= 6.0 (area {:shape :triangle :base 4 :height 3})))))
(deftest multimethod-unknown-dispatch-test
;; Test that unknown shapes throw appropriately
(is (thrown? IllegalArgumentException
(area {:shape :hexagon :side 5}))))For the default method, test it explicitly:
(defmethod area :default [shape]
(throw (ex-info "Unknown shape" {:shape (:shape shape)})))
(deftest unknown-shape-test
(let [ex (try
(area {:shape :pentagon})
(catch clojure.lang.ExceptionInfo e e))]
(is (instance? clojure.lang.ExceptionInfo ex))
(is (= :pentagon (-> ex ex-data :shape)))))Testing Macros
Macros need two kinds of tests: expansion tests and behavior tests.
(ns myapp.macros
(:require [clojure.test :refer :all]))
(defmacro when-let* [bindings & body]
(if (empty? bindings)
`(do ~@body)
(let [[sym val & rest] bindings]
`(when-let [~sym ~val]
(when-let* [~@rest] ~@body)))))
;; Test expansion
(deftest when-let*-expansion-test
(is (= '(clojure.core/when-let [a 1]
(clojure.core/when-let [b 2]
(+ a b)))
(macroexpand-1 '(when-let* [a 1 b 2] (+ a b))))))
;; Test behavior
(deftest when-let*-behavior-test
(testing "all bindings succeed"
(is (= 3 (when-let* [a 1 b 2] (+ a b)))))
(testing "first binding fails"
(is (nil? (when-let* [a nil b 2] (+ a b)))))
(testing "second binding fails"
(is (nil? (when-let* [a 1 b nil] (+ a b)))))
(testing "empty bindings"
(is (= 42 (when-let* [] 42)))))Use macroexpand-1 to test one level of expansion, macroexpand to test full expansion. For macros that generate code you then evaluate, use eval carefully in tests — but prefer behavioral tests over expansion tests when possible since they're more robust to refactoring.
Organizing Test Namespaces
Mirror the Source Structure
src/
myapp/
core.clj
db.clj
web/
routes.clj
middleware.clj
test/
myapp/
core_test.clj
db_test.clj
web/
routes_test.clj
middleware_test.cljThis convention means you always know where tests live, and editors can jump between source and test with a single command.
Shared Test Utilities
Extract test helpers into dedicated namespaces:
(ns myapp.test.helpers
(:require [next.jdbc :as jdbc]
[myapp.db :as db]))
(defn with-test-db [f]
(let [conn (jdbc/get-connection test-db-spec)]
(db/run-migrations! conn)
(binding [db/*conn* conn]
(f))
(.close conn)))
(defn create-test-user! [overrides]
(db/create-user! db/*conn*
(merge {:name "Test User"
:email "test@example.com"
:role :user}
overrides)))
(defn assert-response-ok [response]
(is (= 200 (:status response)))
(is (map? (:body response))))Import these in tests:
(ns myapp.users-test
(:require [clojure.test :refer :all]
[myapp.test.helpers :refer [with-test-db create-test-user!
assert-response-ok]]
[myapp.users :as users]))
(use-fixtures :once with-test-db)Test Hierarchy with testing
The testing macro provides nested context that appears in failure messages:
(deftest user-validation-test
(testing "name validation"
(testing "nil name"
(is (= [:name "Name is required"]
(first (validate-user {:name nil :email "a@b.com"})))))
(testing "empty string name"
(is (= [:name "Name is required"]
(first (validate-user {:name "" :email "a@b.com"})))))
(testing "name too long"
(is (= [:name "Name must be 100 characters or less"]
(first (validate-user {:name (apply str (repeat 101 "a"))
:email "a@b.com"}))))))
(testing "email validation"
(testing "missing @ symbol"
(is (some #(= :email (first %))
(validate-user {:name "Alice" :email "notanemail"}))))))When the "nil name" test fails, the output shows: FAIL in (user-validation-test) (core_test.clj:12) name validation nil name.
Running Tests with deps.edn
Modern Clojure projects use deps.edn. Configure test runners as aliases:
;; deps.edn
{:paths ["src"]
:deps {org.clojure/clojure {:mvn/version "1.11.1"}}
:aliases
{:test {:extra-paths ["test"]
:extra-deps {io.github.cognitect-labs/test-runner
{:git/tag "v0.5.1"
:git/sha "dfb30dd"}}
:main-opts ["-m" "cognitect.test-runner"]
:exec-fn cognitect.test-runner.api/test
:exec-args {:dirs ["test"]}}
:test/watch {:extra-paths ["test"]
:extra-deps {com.cognitect/test-runner
{:git/tag "v0.5.1"
:git/sha "dfb30dd"}
hawk/hawk {:mvn/version "0.2.11"}}
:main-opts ["-m" "hawk.core" "--watch" "src" "test"]}}}Run all tests:
clojure -M:test
# or
clojure -X:testRun specific namespaces:
clojure -X:test :nses '[myapp.core-test myapp.db-test]'Run tests matching a pattern:
clojure -X:test :patterns '["myapp.*-test"]'Kaocha for Advanced Test Running
Kaocha provides more control over test execution:
;; deps.edn
{:aliases
{:kaocha {:extra-deps {lambdaisland/kaocha {:mvn/version "1.87.1366"}}
:main-opts ["-m" "kaocha.runner"]}}};; tests.edn
#kaocha/v1
{:tests [{:id :unit
:test-paths ["test"]
:source-paths ["src"]}]
:reporter kaocha.report/documentation
:color? true
:fail-fast? false}bin/kaocha # run all
bin/kaocha --fail-fast # stop on first failure
bin/kaocha unit # run :unit suite only
bin/kaocha --watch # watch modeKaocha's --documentation reporter shows test names hierarchically, making large suites scannable.
Testing Private Functions
Clojure's ^:private metadata prevents direct access, but tests sometimes need it. Two approaches:
;; Approach 1: Use the var directly
(deftest private-fn-test
(is (= 42 (#'myapp.core/private-helper 21))))
;; Approach 2: Use with-redefs to test behavior through public API
(deftest behavior-through-public-api
(with-redefs [myapp.core/private-helper (fn [x] (* x 3))]
(is (= 63 (myapp.core/public-fn 21)))))The first approach is a direct call via the var. The second tests the private function's effect indirectly. Prefer the second when the function's behavior matters for public contracts; use the first sparingly when you need precise coverage of internal logic.
Parallel Test Execution
clojure.test runs tests sequentially by default. For independent tests, run namespaces in parallel:
;; deps.edn alias for parallel execution
{:aliases
{:test/parallel
{:extra-deps {nubank/matcher-combinators {:mvn/version "3.9.1"}}
:extra-paths ["test"]
:main-opts ["-m" "cognitect.test-runner" "--parallel"]}}}Mark tests that are not safe to run in parallel:
(deftest ^:serial database-migration-test
;; This test modifies global state, cannot run in parallel
...)With Kaocha, configure parallelism per suite:
#kaocha/v1
{:tests [{:id :unit
:parallelism 4}
{:id :integration
:parallelism 1}]}Conclusion
These patterns — composable fixtures, domain-specific assertions, structured test hierarchies, and proper tooling configuration — make clojure.test scale to large codebases. The key insight is that your test code deserves the same care as your production code. Fixtures that clean up after themselves, assertions that produce clear failure messages, and namespaces organized to mirror your source tree all pay dividends as your codebase grows. Start applying them incrementally: pick one pattern per sprint and refactor your most painful test files first.