Testing in Clojure: clojure.test, Midje, and test.check
Clojure's testing ecosystem is unusually rich for a language its size. At the foundation sits clojure.test, included in the standard library and requiring no dependencies. On top of that, Midje adds a readable BDD-style syntax with powerful stubbing. And test.check brings property-based testing — Clojure's port of QuickCheck — for invariant-based testing that generates hundreds of random inputs automatically.
This guide covers all three, from installation through CI, with real examples testing a REST API handler.
clojure.test Basics
clojure.test ships with Clojure itself. You require it and start writing tests immediately:
(ns myapp.core-test
(:require [clojure.test :refer [deftest is are testing run-tests]]
[myapp.core :refer [add multiply parse-age]]))
(deftest test-add
(is (= 4 (add 2 2)))
(is (= 0 (add 0 0)))
(is (= -1 (add 1 -2))))
(deftest test-multiply
(is (= 6 (multiply 2 3)))
(is (= 0 (multiply 0 100)))
(is (= -12 (multiply 3 -4))))deftest defines a named test. is asserts that an expression is truthy. When it fails, clojure.test prints a detailed message showing the expected and actual values.
The testing macro adds nested descriptions for better output:
(deftest test-parse-age
(testing "valid ages"
(is (= {:ok 25} (parse-age "25")))
(is (= {:ok 0} (parse-age "0")))
(is (= {:ok 150} (parse-age "150"))))
(testing "invalid ages"
(is (= {:error "not a number"} (parse-age "abc")))
(is (= {:error "negative"} (parse-age "-5")))
(is (= {:error "too large"} (parse-age "200")))))The are macro reduces boilerplate for table-driven tests:
(deftest test-parse-age-table
(are [input expected] (= expected (parse-age input))
"25" {:ok 25}
"0" {:ok 0}
"abc" {:error "not a number"}
"-5" {:error "negative"}
"200" {:error "too large"}))are expands each row into an individual is assertion, with each failure reported separately.
Running Tests
With Leiningen:
# Run all tests
lein <span class="hljs-built_in">test
<span class="hljs-comment"># Run tests in a specific namespace
lein <span class="hljs-built_in">test myapp.core-test
<span class="hljs-comment"># Run a specific test
lein <span class="hljs-built_in">test :only myapp.core-test/test-parse-ageWith the Clojure CLI (deps.edn):
# Add a test runner alias to deps.edn first
clj -M:<span class="hljs-built_in">test
<span class="hljs-comment"># Or use cognitect test-runner
clj -X:<span class="hljs-built_in">testA minimal deps.edn for testing:
{:paths ["src"]
: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"]}}}}From the REPL, you can run tests interactively:
(require '[clojure.test :refer [run-tests run-all-tests]])
;; Run tests in a specific namespace
(run-tests 'myapp.core-test)
;; Run everything loaded
(run-all-tests)
;; Re-require namespace to pick up changes, then run
(require 'myapp.core-test :reload)
(run-tests 'myapp.core-test)Test Namespaces and Naming Conventions
The convention is to mirror source namespaces with a -test suffix:
src/
myapp/
core.clj → test/myapp/core_test.clj
api/
handler.clj → test/myapp/api/handler_test.clj
db/
queries.clj → test/myapp/db/queries_test.cljThe test namespace requires the namespace under test:
(ns myapp.api.handler-test
(:require [clojure.test :refer :all]
[myapp.api.handler :as handler]))Fixtures
Fixtures run setup and teardown around tests. They come in two flavors: :once (per namespace) and :each (per test):
(ns myapp.db.queries-test
(:require [clojure.test :refer :all]
[myapp.db.connection :as db]
[myapp.db.queries :as q]))
(def test-db (atom nil))
;; Runs once for the entire namespace
(defn db-fixture [f]
(reset! test-db (db/create-test-database))
(f)
(db/drop-test-database @test-db))
;; Runs before and after each test
(defn clean-fixture [f]
(db/clear-tables @test-db)
(f))
(use-fixtures :once db-fixture)
(use-fixtures :each clean-fixture)
(deftest test-insert-user
(q/insert-user @test-db {:id 1 :name "Alice" :email "alice@example.com"})
(is (= "Alice" (:name (q/find-user @test-db 1)))))
(deftest test-delete-user
(q/insert-user @test-db {:id 1 :name "Alice" :email "alice@example.com"})
(q/delete-user @test-db 1)
(is (nil? (q/find-user @test-db 1))))Multiple fixtures can be composed:
(use-fixtures :once
start-database-fixture
run-migrations-fixture)
(use-fixtures :each
clean-tables-fixture
seed-test-data-fixture)Mocking with with-redefs
with-redefs temporarily replaces a function's definition for the duration of a body:
(ns myapp.email-test
(:require [clojure.test :refer :all]
[myapp.email :as email]
[myapp.mailer :as mailer]))
(deftest test-send-welcome-email
(let [sent (atom [])]
(with-redefs [mailer/send! (fn [msg] (swap! sent conj msg))]
(email/send-welcome! {:id 1 :email "alice@example.com" :name "Alice"})
(is (= 1 (count @sent)))
(is (= "alice@example.com" (:to (first @sent))))
(is (clojure.string/includes? (:body (first @sent)) "Alice")))))with-redefs works across threads and namespaces — it's the standard way to stub external dependencies in Clojure tests. Pair it with atoms when you need to capture calls:
(deftest test-payment-processor
(let [charges (atom [])
errors (atom [])]
(with-redefs [stripe/charge! (fn [amount card]
(if (pos? amount)
(do (swap! charges conj {:amount amount :card card})
{:status :ok :charge-id "ch_123"})
(do (swap! errors conj amount)
{:status :error :message "Invalid amount"})))]
(testing "successful charge"
(let [result (payment/process! {:amount 999 :card "tok_visa"})]
(is (= :ok (:status result)))
(is (= 1 (count @charges)))))
(testing "zero amount rejected"
(let [result (payment/process! {:amount 0 :card "tok_visa"})]
(is (= :error (:status result))))))))Midje: BDD-Style Testing
Midje provides a more expressive syntax built around the fact macro. Install it in project.clj:
:dependencies [[midje "1.10.9"]]
:plugins [[lein-midje "3.2.2"]]Or in deps.edn:
{:aliases
{:midje {:extra-deps {midje/midje {:mvn/version "1.10.9"}}
:main-opts ["-m" "midje.repl"]}}}Midje tests use fact and facts with the => arrow:
(ns myapp.core-test
(:require [midje.sweet :refer :all]
[myapp.core :refer :all]))
(fact "addition works"
(add 2 3) => 5
(add 0 0) => 0
(add -1 1) => 0)
(facts "about parsing ages"
(fact "valid ages are accepted"
(parse-age "25") => {:ok 25}
(parse-age "0") => {:ok 0})
(fact "invalid ages return errors"
(parse-age "abc") => {:error "not a number"}
(parse-age "-5") => {:error "negative"}))The => arrow reads naturally: "calling (add 2 3) gives 5." Midje also has an optional against-background and description on the left of the arrow:
(fact "parse-age returns ok for valid strings"
(parse-age "25") => {:ok 25})Midje Checkers
Midje has built-in checkers for flexible assertions:
(fact "approximate matching"
(calculate-tax 100.0) => (roughly 20.0 0.5))
(fact "map contains these keys"
(get-user 1) => (contains {:name "Alice" :email "alice@example.com"}))
(fact "collection contains these items"
(get-tags) => (contains ["clojure" "testing"] :in-any-order))
(fact "throws an exception"
(parse-age nil) => (throws NullPointerException))
(fact "throws with message"
(connect "bad-url") => (throws Exception #"Connection refused"))Midje Stubs with provided
Midje's provided keyword stubs function calls inline with the fact:
(fact "send-welcome-email calls the mailer"
(email/send-welcome! {:id 1 :email "alice@example.com" :name "Alice"}) => :ok
(provided
(mailer/send! anything) => :ok))
(fact "payment fails when stripe is down"
(payment/process! {:amount 100 :card "tok_visa"}) => {:status :error}
(provided
(stripe/charge! 100 "tok_visa") =throws=> (ex-info "Timeout" {})))provided is checked automatically — if mailer/send! is never called, the test fails.
Run Midje tests:
lein midje # run all
lein midje myapp.core-test <span class="hljs-comment"># run one namespace
lein midje :autotest <span class="hljs-comment"># watch and re-runtest.check: Property-Based Testing
test.check is Clojure's implementation of property-based testing. Add it:
;; project.clj
:dependencies [[org.clojure/test.check "1.1.1"]]
;; deps.edn
{:deps {org.clojure/test.check {:mvn/version "1.1.1"}}}Basic usage:
(ns myapp.core-test
(:require [clojure.test.check :as tc]
[clojure.test.check.generators :as gen]
[clojure.test.check.properties :as prop]))
;; Define a property
(def prop-reverse-reverse
(prop/for-all [xs (gen/vector gen/int)]
(= xs (reverse (reverse xs)))))
;; Run it
(tc/quick-check 100 prop-reverse-reverse)
;; => {:result true, :num-tests 100, :seed 1234567890}Generators:
gen/int ; any integer
gen/nat ; non-negative integer
gen/string ; any string
gen/boolean ; true or false
gen/keyword ; any keyword
gen/uuid ; random UUID
;; Collections
(gen/vector gen/int) ; vector of ints
(gen/list gen/string) ; list of strings
(gen/map gen/keyword gen/int) ; map of keyword->int
(gen/set gen/nat) ; set of non-negative ints
;; Refined generators
(gen/choose 1 100) ; int between 1 and 100 inclusive
(gen/elements [:a :b :c]) ; one of these values
(gen/such-that pos? gen/int) ; filtered generator (use sparingly)
(gen/fmap str gen/int) ; transform generator outputCombining test.check with clojure.test (defspec)
The clojure.test.check.clojure-test namespace provides defspec for integration with clojure.test:
(ns myapp.sorting-test
(:require [clojure.test :refer :all]
[clojure.test.check.clojure-test :refer [defspec]]
[clojure.test.check.generators :as gen]
[clojure.test.check.properties :as prop]
[myapp.sort :refer [mergesort]]))
;; Run 200 tests
(defspec sort-produces-sorted-output 200
(prop/for-all [xs (gen/vector gen/int)]
(let [sorted (mergesort xs)]
(every? true?
(map <= sorted (rest sorted))))))
(defspec sort-preserves-elements 200
(prop/for-all [xs (gen/vector gen/int)]
(= (sort xs) (mergesort xs))))
(defspec sort-is-idempotent 100
(prop/for-all [xs (gen/vector gen/int)]
(= (mergesort xs) (mergesort (mergesort xs)))))defspec integrates with lein test and clj -M:test — properties run alongside regular deftest tests.
REPL-Driven Testing Workflow
Clojure's interactive development shines in the test workflow:
;; In your REPL
(require '[myapp.core :as core] :reload)
(require '[myapp.core-test :as t] :reload)
;; Run a single test
(t/test-parse-age)
;; Run all tests in namespace
(clojure.test/run-tests 'myapp.core-test)
;; Try a specific case interactively
(core/parse-age "abc")
;; => {:error "not a number"}
;; Run a quick-check property
(require '[clojure.test.check :as tc])
(tc/quick-check 50 t/prop-reverse-reverse)The REPL loop — edit, reload, test, inspect — is Clojure's killer feature for development.
CI with GitHub Actions
# .github/workflows/test.yml
name: Clojure Tests
on:
push:
branches: [main]
pull_request:
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Set up Java
uses: actions/setup-java@v3
with:
distribution: temurin
java-version: '21'
- name: Install Clojure CLI
uses: DeLaGuardo/setup-clojure@12
with:
cli: latest
- name: Cache Clojure dependencies
uses: actions/cache@v3
with:
path: |
~/.m2/repository
~/.gitlibs
~/.deps.clj
key: ${{ runner.os }}-clojure-${{ hashFiles('**/deps.edn', '**/project.clj') }}
- name: Run tests
run: clj -X:testFor Leiningen projects, replace the last step:
- name: Install Leiningen
uses: DeLaGuardo/setup-clojure@12
with:
lein: latest
- name: Run tests
run: lein testReal Example: Testing a REST API Handler
Here's a complete test suite for a Ring-based HTTP handler:
;; src/myapp/api/users.clj
(ns myapp.api.users
(:require [myapp.db.users :as db]
[cheshire.core :as json]))
(defn get-user [request]
(let [id (Integer/parseInt (get-in request [:path-params :id]))]
(if-let [user (db/find-user id)]
{:status 200
:headers {"Content-Type" "application/json"}
:body (json/generate-string user)}
{:status 404
:body (json/generate-string {:error "User not found"})})))
(defn create-user [request]
(let [body (json/parse-string (:body request) true)]
(cond
(not (:name body))
{:status 400 :body (json/generate-string {:error "name is required"})}
(not (:email body))
{:status 400 :body (json/generate-string {:error "email is required"})}
:else
(let [user (db/insert-user body)]
{:status 201
:headers {"Content-Type" "application/json"}
:body (json/generate-string user)}))));; test/myapp/api/users_test.clj
(ns myapp.api.users-test
(:require [clojure.test :refer :all]
[cheshire.core :as json]
[myapp.api.users :as handler]
[myapp.db.users :as db]))
(defn make-request
([method path] (make-request method path nil))
([method path body]
{:request-method method
:path-params (when (re-find #"/users/(\d+)" path)
{:id (second (re-find #"/users/(\d+)" path))})
:body (when body (json/generate-string body))}))
(def test-user {:id 42 :name "Alice" :email "alice@example.com"})
(deftest test-get-user-found
(with-redefs [db/find-user (fn [id] (when (= id 42) test-user))]
(let [response (handler/get-user (make-request :get "/users/42"))]
(is (= 200 (:status response)))
(is (= test-user (json/parse-string (:body response) true))))))
(deftest test-get-user-not-found
(with-redefs [db/find-user (constantly nil)]
(let [response (handler/get-user (make-request :get "/users/99"))]
(is (= 404 (:status response)))
(is (= "User not found" (:error (json/parse-string (:body response) true)))))))
(deftest test-create-user-success
(let [created (atom nil)]
(with-redefs [db/insert-user (fn [user]
(let [u (assoc user :id 1)]
(reset! created u)
u))]
(let [response (handler/create-user
(make-request :post "/users"
{:name "Bob" :email "bob@example.com"}))]
(is (= 201 (:status response)))
(is (= "Bob" (:name @created)))
(is (= "bob@example.com" (:email @created)))))))
(deftest test-create-user-missing-name
(let [response (handler/create-user
(make-request :post "/users" {:email "bob@example.com"}))]
(is (= 400 (:status response)))
(is (= "name is required"
(:error (json/parse-string (:body response) true))))))
(deftest test-create-user-missing-email
(let [response (handler/create-user
(make-request :post "/users" {:name "Bob"}))]
(is (= 400 (:status response)))
(is (= "email is required"
(:error (json/parse-string (:body response) true))))))This handler test suite covers the happy path, not-found, and validation errors — all with with-redefs to avoid a real database.
Common Pitfalls
Don't test private functions. In Clojure, defn- defines a private function. Test the public API; if private functions need testing, they probably should be extracted into their own namespace.
Be careful with such-that. (gen/such-that pred gen) can loop forever if the predicate rejects most generated values. Use fmap or targeted generators instead.
with-redefs is not thread-safe for parallel tests. If you run tests in parallel (e.g., with lein test :parallel), avoid with-redefs — use a test-specific dependency injection pattern instead.
Fixtures run in declaration order. If your database fixture depends on a schema migration fixture, declare migrations first.
HelpMeTest adds 24/7 monitoring and AI-powered test generation for Clojure services — start free at helpmetest.com