Testing in Clojure: clojure.test, Midje, and test.check

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-age

With 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">test

A 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.clj

The 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-run

test.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 output

Combining 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:test

For Leiningen projects, replace the last step:

      - name: Install Leiningen
        uses: DeLaGuardo/setup-clojure@12
        with:
          lein: latest

      - name: Run tests
        run: lein test

Real 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

Read more