Kaocha: The Modern Clojure Test Runner
Kaocha is the test runner that Clojure projects reach for when clojure.test and lein test stop being enough. It adds test suites, plugin architecture, a watch mode, and rich output formatting — while staying fully compatible with standard clojure.test tests. This guide covers the setup and patterns that matter in production Clojure projects.
Why Kaocha
The built-in clojure.test is fine for writing tests. The runner that ships with Leiningen and tools.deps is not fine for large projects:
- No test filtering by name or namespace
- No watch mode for development
- No way to separate unit and integration test suites
- Poor failure output for nested data structures
Kaocha solves all of these while remaining 100% compatible with tests written in clojure.test, Midje, or test.check.
Setup with tools.deps
;; deps.edn
{:paths ["src" "resources"]
:deps {org.clojure/clojure {:mvn/version "1.11.1"}}
:aliases
{:test
{:extra-paths ["test"]
:extra-deps {lambdaisland/kaocha {:mvn/version "1.87.1366"}}
:main-opts ["-m" "kaocha.runner"]}}}Run tests:
clojure -M:testBasic Configuration
Kaocha reads from tests.edn at the project root:
;; tests.edn
#kaocha/v1
{:tests [{:id :unit
:test-paths ["test/unit"]
:source-paths ["src"]}]}Run a specific suite:
clojure -M:test --suite unitMultiple Test Suites
Separating fast and slow tests is the most common reason to use Kaocha:
;; tests.edn
#kaocha/v1
{:tests
[{:id :unit
:test-paths ["test/unit"]
:ns-patterns [".*-test$"]}
{:id :integration
:test-paths ["test/integration"]
:ns-patterns [".*-integration$"]}
{:id :e2e
:test-paths ["test/e2e"]
:ns-patterns [".*-e2e$"]
:skip true}]} ; skip by default, include explicitly# Fast local development loop
clojure -M:test --suite unit
# Full CI run
clojure -M:test --suite unit --suite integration
# Including e2e
clojure -M:test --suite unit --suite integration --suite e2eWatch Mode
Kaocha's watch mode re-runs tests on file changes:
clojure -M:test --watchConfigure what triggers a re-run:
;; tests.edn
#kaocha/v1
{:watch? true
:color? true
:tests [{:id :unit
:test-paths ["test/unit"]}]}In watch mode, Kaocha only re-runs tests in namespaces that were affected by the change. This keeps the feedback loop fast even in large projects.
Focus and Skip
Run a single test or namespace without modifying code:
# Run tests matching a string
clojure -M:test --focus "my-app.user-test"
# Focus on a specific test var
clojure -M:test --focus "my-app.user-test/creates-user-with-valid-data"
# Skip a namespace
clojure -M:test --skip "my-app.slow-integration-test"In code, use metadata:
(ns my-app.user-test
(:require [clojure.test :refer :all]
[my-app.users :as users]))
(deftest ^:focus creates-user-with-valid-data
;; Kaocha runs only ^:focus tests when any are present
(is (some? (users/create! {:email "alice@example.com"}))))
(deftest ^:skip flaky-network-test
;; Skipped unless explicitly included
(is (= 200 (some-http-call))))Plugins
Kaocha's plugin system is its killer feature. Plugins are just functions that hook into the test lifecycle.
Built-in Plugins
;; tests.edn
#kaocha/v1
{:plugins [kaocha.plugin/randomize ; randomize test order
kaocha.plugin/filter ; enable --focus/--skip CLI flags
kaocha.plugin/capture-output ; capture stdout/stderr
kaocha.plugin/gc-profiling ; report GC pressure
kaocha.plugin/profiling]} ; report 10 slowest testsThe profiling plugin is invaluable — it shows which tests are slowing down your suite:
Top 10 slowest tests (2.49s):
my-app.db-test/inserts-1000-users 1.23s
my-app.search-test/full-text-search 0.45s
my-app.email-test/sends-confirmation 0.31sjunit.xml for CI
;; deps.edn — add junit plugin
{:aliases
{:test
{:extra-deps {lambdaisland/kaocha {:mvn/version "1.87.1366"}
lambdaisland/kaocha-junit-xml {:mvn/version "1.17.101"}}}}};; tests.edn
#kaocha/v1
{:plugins [kaocha.plugin/junit-xml]
:kaocha.plugin.junit-xml/target-file "target/test-results/results.xml"
:tests [{:id :unit
:test-paths ["test/unit"]}]}GitHub Actions picks up JUnit XML automatically when configured:
- name: Run tests
run: clojure -M:test
- name: Publish test results
uses: EnricoMi/publish-unit-test-result-action@v2
with:
files: target/test-results/*.xmlIntegration with test.check
Kaocha runs generative tests defined with clojure.test.check:
(ns my-app.math-test
(:require [clojure.test :refer :all]
[clojure.test.check.generators :as gen]
[clojure.test.check.properties :as prop]
[clojure.test.check.clojure-test :refer [defspec]]))
(defspec addition-is-commutative
100 ; number of trials
(prop/for-all [a gen/int
b gen/int]
(= (+ a b) (+ b a))))
(defspec addition-identity
100
(prop/for-all [n gen/int]
(= n (+ n 0))))Kaocha reports these as regular tests. When a property fails, test.check shrinks the failing case and Kaocha shows the minimal counterexample.
Configure the number of trials globally:
;; tests.edn
#kaocha/v1
{:tests [{:id :unit
:test-paths ["test"]
:clojure.test.check/opts {:num-tests 200}}]}Test Fixtures and State
clojure.test fixtures work normally with Kaocha:
(ns my-app.db-test
(:require [clojure.test :refer :all]
[my-app.db :as db]))
(def ^:dynamic *conn* nil)
(defn with-database [f]
(let [conn (db/connect "jdbc:h2:mem:test")]
(binding [*conn* conn]
(try
(f)
(finally
(db/rollback conn)
(db/close conn))))))
(use-fixtures :each with-database)
(deftest creates-record
(let [id (db/insert! *conn* {:name "Test"})]
(is (some? id))
(is (= "Test" (:name (db/get-by-id *conn* id))))))For state that should persist across all tests in a namespace (like a started server), use :once fixtures:
(use-fixtures :once
(fn [f]
(let [server (start-test-server!)]
(try (f)
(finally (stop-server! server))))))Custom Reporter
Override the output format:
;; tests.edn — use documentation reporter (verbose)
#kaocha/v1
{:reporter kaocha.report/documentation
:tests [{:id :unit}]}Available reporters:
kaocha.report/progress— dots (default)kaocha.report/documentation— full test nameskaocha.report/tap— TAP formatkaocha.report.print-invocations/printer— prints each assertion
CI Configuration
A complete GitHub Actions workflow:
name: Tests
on: [push, pull_request]
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Setup Java
uses: actions/setup-java@v4
with:
distribution: temurin
java-version: 21
- name: Install Clojure CLI
uses: DeLaGuardo/setup-clojure@12
with:
cli: 1.11.1.1413
- name: Cache deps
uses: actions/cache@v3
with:
path: ~/.m2/repository
key: ${{ runner.os }}-maven-${{ hashFiles('deps.edn') }}
- name: Run unit tests
run: clojure -M:test --suite unit --reporter kaocha.report/documentation
- name: Run integration tests
run: clojure -M:test --suite integrationFail Fast
Stop on first failure during development:
clojure -M:test --fail-fastIn tests.edn:
#kaocha/v1
{:fail-fast? true
:tests [{:id :unit}]}Summary
Kaocha is what clojure.test would be if it were designed for large projects. The suite system lets you separate test categories with different triggers. Watch mode makes the development loop fast. Plugins add JUnit XML, profiling, and output capture without changing test code. And everything remains compatible with standard clojure.test — no rewriting required.
The migration path is zero-cost: add Kaocha to :test alias, create tests.edn, and run clojure -M:test. Existing deftest and defspec tests work immediately.