Kaocha: The Modern Clojure Test Runner

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

Basic 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 unit

Multiple 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 e2e

Watch Mode

Kaocha's watch mode re-runs tests on file changes:

clojure -M:test --watch

Configure 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 tests

The 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.31s

junit.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/*.xml

Integration 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 names
  • kaocha.report/tap — TAP format
  • kaocha.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 integration

Fail Fast

Stop on first failure during development:

clojure -M:test --fail-fast

In 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.

Read more

Start now free