Introduction to Testing in Clojure
Clojure, as a functional language running on the JVM, offers unique testing opportunities thanks to its emphasis on immutability, pure functions, and REPL-driven development. A well-structured testing strategy in Clojure not only catches bugs early but also serves as living documentation for your codebase. In this tutorial, we'll explore the testing landscape in Clojure, from the built-in clojure.test framework to property-based testing and integration strategies.
Why Testing Matters in Clojure
While Clojure's functional paradigm naturally reduces side-effect-related bugs, testing remains essential for several reasons:
- Pure functions are easy to test — given the same input, they always produce the same output, making them ideal candidates for unit testing.
- Refactoring confidence — Clojure codebases often evolve through heavy refactoring; tests ensure behavior remains stable.
- Documentation — well-written tests demonstrate how functions are expected to be used.
- Regression prevention — as your application grows, tests catch unintended side effects of changes.
- Concurrency safety — tests can verify that your concurrent constructs behave correctly under load.
The Built-in clojure.test Framework
Clojure ships with a built-in testing framework called clojure.test. It provides macros for defining tests, assertions, and fixtures. Let's start with a simple example.
Basic Test Structure
First, let's define a namespace with some functions to test:
(ns myapp.core)
(defn factorial
"Returns the factorial of n."
[n]
(if (zero? n)
1
(* n (factorial (dec n)))))
(defn fibonacci
"Returns the nth Fibonacci number."
[n]
(cond
(= n 0) 0
(= n 1) 1
:else (+ (fibonacci (- n 1))
(fibonacci (- n 2)))))
(defn classify-number
"Classifies a number as positive, negative, or zero."
[n]
(cond
(pos? n) :positive
(neg? n) :negative
:else :zero))
Now let's write tests for these functions:
(ns myapp.core-test
(:require [clojure.test :refer :all]
[myapp.core :refer :all]))
(deftest factorial-test
(testing "factorial of 0 is 1"
(is (= 1 (factorial 0))))
(testing "factorial of positive numbers"
(is (= 1 (factorial 1)))
(is (= 2 (factorial 2)))
(is (= 6 (factorial 3)))
(is (= 120 (factorial 5))))
(testing "factorial throws on negative input"
(is (thrown? StackOverflowError (factorial -1)))))
(deftest fibonacci-test
(testing "base cases"
(is (= 0 (fibonacci 0)))
(is (= 1 (fibonacci 1))))
(testing "recursive cases"
(is (= 1 (fibonacci 2)))
(is (= 2 (fibonacci 3)))
(is (= 5 (fibonacci 5)))
(is (= 21 (fibonacci 8)))))
(deftest classify-number-test
(testing "positive numbers"
(is (= :positive (classify-number 1)))
(is (= :positive (classify-number 100))))
(testing "negative numbers"
(is (= :negative (classify-number -1)))
(is (= :negative (classify-number -100))))
(testing "zero"
(is (= :zero (classify-number 0)))))
(run-tests)
Using are for Data-Driven Tests
When you have many similar assertions, the are macro helps reduce boilerplate:
(deftest fibonacci-table-test
(testing "fibonacci sequence values"
(are [n expected] (= expected (fibonacci n))
0 0
1 1
2 1
3 2
4 3
5 5
6 8
7 13
8 21
9 34
10 55)))
Testing Exceptions
The thrown? and thrown-with-msg? macros let you verify that code throws expected exceptions:
(ns myapp.validation-test
(:require [clojure.test :refer :all]
[myapp.validation :as v]))
(deftest validation-test
(testing "invalid email throws exception"
(is (thrown? IllegalArgumentException
(v/validate-email "not-an-email"))))
(testing "exception has correct message"
(is (thrown-with-msg? IllegalArgumentException
#"Invalid email format"
(v/validate-email "bad")))))
Test Fixtures
Fixtures allow you to set up and tear down state before and after tests. This is particularly useful for database connections, external services, or any stateful setup.
One-Time Fixtures
One-time fixtures run once before all tests in a namespace and once after:
(ns myapp.db-test
(:require [clojure.test :refer :all]
[myapp.db :as db]
[myapp.test-helpers :as helpers]))
(defn db-fixture
"Sets up a test database, runs tests, then tears it down."
[f]
(helpers/setup-test-db!)
(f)
(helpers/teardown-test-db!))
(use-fixtures :once db-fixture)
Each-Time Fixtures
Each-time fixtures run before and after every single test:
(defn clean-db-fixture
"Cleans the database before each test."
[f]
(helpers/clean-tables!)
(f)
(helpers/clean-tables!))
(use-fixtures :each clean-db-fixture)
Combining Multiple Fixtures
(use-fixtures :once db-fixture load-seed-data-fixture)
(use-fixtures :each clean-db-fixture reset-config-fixture)
Testing with Mocks and Stubs
When testing functions that depend on external services, you'll want to isolate them using mocks and stubs. Clojure offers several approaches.
Using with-redefs
The with-redefs macro temporarily redefines vars, which is perfect for stubbing:
(ns myapp.weather-test
(:require [clojure.test :refer :all]
[myapp.weather :as weather]
[myapp.http :as http]))
(deftest get-temperature-test
(testing "parses temperature from API response"
(with-redefs [http/get (fn [url]
(when (= url "https://api.weather.com/temp")
{:status 200
:body "{\"temp\": 22.5}"}))]
(is (= 22.5 (weather/get-temperature)))))
(testing "returns nil on API error"
(with-redefs [http/get (fn [_] (throw (ex-info "Connection error" {})))]
(is (nil? (weather/get-temperature))))))
Using the with-redefs-fn for Dynamic Stubbing
(deftest dynamic-stub-test
(let [call-count (atom 0)]
(with-redefs [http/get (fn [_]
(swap! call-count inc)
{:status 200 :body "{}"})]
(weather/refresh-cache)
(weather/refresh-cache)
(is (= 2 @call-count)))))
Property-Based Testing with test.check
Property-based testing is a powerful technique where you define properties that should hold true for all valid inputs, and the framework generates random test cases to verify them. The org.clojure/test.check library brings this to Clojure.
Setting Up test.check
Add test.check to your project.clj:
(defproject myapp "0.1.0-SNAPSHOT"
:dependencies [[org.clojure/clojure "1.11.1"]]
:dev-dependencies [[org.clojure/test.check "1.1.1"]])
Or in deps.edn:
{:deps {org.clojure/clojure {:mvn/version "1.11.1"}}
:test {:extra-deps {org.clojure/test.check {:mvn/version "1.1.1"}}}}
Writing Property Tests
(ns myapp.core-prop-test
(:require [clojure.test :refer :all]
[clojure.test.check :as tc]
[clojure.test.check.generators :as gen]
[clojure.test.check.properties :as prop]
[myapp.core :refer :all]))
(deftest factorial-property-test
(testing "factorial is always positive for non-negative integers"
(let [prop-positive (prop/for-all [n (gen/choose 0 20)]
(pos? (factorial n)))]
(is (:result (tc/quick-check 100 prop-positive)))))
(testing "factorial satisfies recurrence relation: n! = n * (n-1)!"
(let [prop-recurrence (prop/for-all [n (gen/choose 1 20)]
(= (factorial n)
(* n (factorial (dec n)))))]
(is (:result (tc/quick-check 100 prop-recurrence))))))
(deftest classify-number-property-test
(testing "every integer is classified as positive, negative, or zero"
(let [prop-classified (prop/for-all [n gen/int]
(#{:positive :negative :zero}
(classify-number n)))]
(is (:result (tc/quick-check 1000 prop-classified)))))
(testing "positive integers are classified as :positive"
(let [prop-positive (prop/for-all [n (gen/choose 1 10000)]
(= :positive (classify-number n)))]
(is (:result (tc/quick-check 100 prop-positive))))))
Custom Generators
You can create custom generators for domain-specific data:
(ns myapp.user-test
(:require [clojure.test :refer :all]
[clojure.test.check :as tc]
[clojure.test.check.generators :as gen]
[clojure.test.check.properties :as prop]
[myapp.user :as user]))
(def email-gen
"Generates plausible email addresses."
(gen/fmap (fn [[local domain tld]]
(str local "@" domain "." tld))
(gen/tuple (gen/not-empty gen/string-alphanumeric)
(gen/not-empty gen/string-alphanumeric)
(gen/elements ["com" "org" "net" "io"]))))
(def user-gen
"Generates user maps with name, email, and age."
(gen/hash-map
:name (gen/not-empty gen/string-alphanumeric)
:email email-gen
:age (gen/choose 0 120)))
(deftest user-validation-property-test
(testing "valid users pass validation"
(let [prop-valid (prop/for-all [u user-gen]
(nil? (:error (user/validate u))))]
(is (:result (tc/quick-check 200 prop-valid))))))
Integration Testing
Integration tests verify that multiple components work together correctly. In Clojure web applications, this often means testing HTTP endpoints end-to-end.
Testing Ring Applications
(ns myapp.handler-test
(:require [clojure.test :refer :all]
[ring.mock.request :as mock]
[myapp.handler :as handler]
[myapp.db :as db]
[myapp.test-helpers :as helpers]))
(use-fixtures :each
(fn [f]
(helpers/setup-test-db!)
(f)
(helpers/teardown-test-db!)))
(deftest api-routes-test
(testing "GET /health returns 200"
(let [response (handler/app (mock/request :get "/health"))]
(is (= 200 (:status response)))
(is (= "ok" (:body response)))))
(testing "GET /users returns user list"
(db/create-user! {:name "Alice" :email "alice@example.com"})
(db/create-user! {:name "Bob" :email "bob@example.com"})
(let [response (handler/app (mock/request :get "/users"))
body (helpers/parse-body response)]
(is (= 200 (:status response)))
(is (= 2 (count body)))
(is (= "Alice" (-> body first :name)))))
(testing "POST /users creates a new user"
(let [response (handler/app
(-> (mock/request :post "/users")
(mock/json-body {:name "Charlie"
:email "charlie@example.com"})))]
(is (= 201 (:status response)))
(is (some? (db/find-user-by-email "charlie@example.com")))))
(testing "GET /nonexistent returns 404"
(let [response (handler/app (mock/request :get "/nonexistent"))]
(is (= 404 (:status response))))))
Testing with a Real HTTP Server
For full integration tests, you may want to start the actual server:
(ns myapp.integration-test
(:require [clojure.test :refer :all]
[clj-http.client :as http]
[myapp.core :as core]
[myapp.config :as config])
(:import [org.eclipse.jetty.server Server]))
(def ^:dynamic *server* nil)
(def ^:dynamic *base-url* nil)
(defn server-fixture [f]
(let [port (config/find-free-port)
server (core/start-server {:port port})]
(binding [*server* server
*base-url* (str "http://localhost:" port)]
(f)
(.stop server))))
(use-fixtures :once server-fixture)
(deftest full-stack-test
(testing "health check endpoint"
(let [response (http/get (str *base-url* "/health"))]
(is (= 200 (:status response)))
(is (= "ok" (:body response)))))
(testing "create and retrieve user"
(http/post (str *base-url* "/users")
{:form-params {:name "Dana" :email "dana@example.com"}
:content-type :json})
(let [response (http/get (str *base-url* "/users/dana@example.com"))]
(is (= 200 (:status response)))
(is (= "Dana" (:name (:body response)))))))
Testing Async and Concurrent Code
Clojure's concurrency primitives (atoms, refs, agents, channels) require careful testing. Here are strategies for testing concurrent code.
Testing Atoms
(ns myapp.counter-test
(:require [clojure.test :refer :all]
[myapp.counter :as counter]))
(deftest counter-test
(testing "increment updates value"
(let [c (counter/->counter 0)]
(counter/increment! c)
(is (= 1 (counter/value c)))))
(testing "concurrent increments are safe"
(let [c (counter/->counter 0)
threads 100
increments-per-thread 1000]
(doall (pmap (fn [_]
(dotimes [_ increments-per-thread]
(counter/increment! c)))
(range threads)))
(is (= (* threads increments-per-thread)
(counter/value c))))))
Testing core.async Channels
(ns myapp.pipeline-test
(:require [clojure.test :refer :all]
[clojure.core.async :as a :refer [>! !! !! in 5)
(>!! in 10)
(is (= 10 (!! in 1)
(close! in)
(is (= 2 (
Test Organization and Naming Conventions
A well-organized test suite is maintainable and easy to navigate. Here are recommended conventions:
Namespace Structure
Mirror your source namespace structure in your tests:
src/myapp/
core.clj -> test/myapp/core_test.clj
db.clj -> test/myapp/db_test.clj
handlers/
user.clj -> test/myapp/handlers/user_test.clj
order.clj -> test/myapp/handlers/order_test.clj
Naming Conventions
- Test namespaces end with
-test - Test functions (deftest) end with
-test - Use
testingblocks to group related assertions with descriptive strings - Property tests end with
-property-test - Integration tests live in a separate namespace or directory
Project Structure with Leiningen
myapp/
project.clj
src/
myapp/
core.clj
db.clj
test/
myapp/
core_test.clj
db_test.clj
test-resources/
fixtures/
users.edn
orders.edn
Running Tests
With Leiningen
# Run all tests
lein test
# Run a specific namespace
lein test myapp.core-test
# Run a specific test
lein test :only myapp.core-test/factorial-test
# Run tests with a filter
lein test :integration
With deps.edn and the Clojure CLI
# Run all tests
clojure -M:test
# Using cognitect-labs test-runner
clojure -X:test
# Run a specific namespace
clojure -M:test -n myapp.core-test
Running Tests in the REPL
REPL-driven development is a core part of the Clojure workflow. You can run tests directly from the REPL:
(require '[clojure.test :refer :all])
(require '[myapp.core-test :as ct])
;; Run all tests in a namespace
(run-tests 'myapp.core-test)
;; Run a specific test
(ct/factorial-test)
;; Run tests with verbose output
(with-redefs [*report-counters* (ref initial-report-counters)]
(ct/factorial-test)
@*report-counters*)
Best Practices
1. Test Pure Functions Extensively
Pure functions are the easiest to test and should have thorough coverage. They form the backbone of your application logic:
(ns myapp.pricing-test
(:require [clojure.test :refer :all]
[myapp.pricing :as pricing]))
(deftest calculate-price-test
(testing "basic price calculation"
(is (= 100.0 (pricing/calculate-price {:base-price 100 :quantity 1}))))
(testing "quantity multiplier"
(is (= 300.0 (pricing/calculate-price {:base-price 100 :quantity 3}))))
(testing "discount application"
(is (= 90.0 (pricing/calculate-price {:base-price 100
:quantity 1
:discount 0.1}))))
(testing "bulk discount stacks with regular discount"
(is (= 216.0 (pricing/calculate-price {:base-price 100
:quantity 3
:discount 0.1
:bulk-discount 0.2})))))
2. Keep Tests Independent
Each test should be able to run in isolation without depending on other tests. Use fixtures to ensure clean state:
(defn isolated-state-fixture [f]
(with-redefs [myapp.config/settings (atom {:env :test})]
(f)))
(use-fixtures :each isolated-state-fixture)
3. Use testing Blocks for Clarity
Group related assertions with descriptive testing blocks. This makes failures easier to understand:
(deftest order-processing-test
(testing "new order gets pending status"
...)
(testing "order transitions to confirmed after payment"
...)
(testing "cancelled order cannot be confirmed"
...)
(testing "order total includes tax and shipping"
...))
4. Test Edge Cases Explicitly
(deftest edge-cases-test
(testing "empty collections"
(is (= 0 (myapp.stats/sum [])))
(is (= nil (myapp.stats/average []))))
(testing "single element"
(is (= 5 (myapp.stats/sum [5])))
(is (= 5.0 (myapp.stats/average [5]))))
(testing "nil values"
(is (thrown? NullPointerException (myapp.stats/sum nil)))
(is (= 0 (myapp.stats/sum-safe nil))))
(testing "very large numbers"
(is (= Long/MAX_VALUE (myapp.stats/sum [Long/MAX_VALUE])))
(is (= 0 (myapp.stats/sum [Long/MIN_VALUE Long/MAX_VALUE])))))
5. Use Property-Based Testing for Algorithms
For algorithms with invariants, property-based testing can find edge cases you might miss:
(deftest sort-property-test
(testing "sorting preserves all elements"
(let [prop-preserve (prop/for-all [v (gen/vector gen/int)]
(= (sort v) (sort (myapp.sort/quicksort v))))]
(is (:result (tc/quick-check 500 prop-preserve)))))
(testing "sorted output is actually sorted"
(let [prop-sorted (prop/for-all [v (gen/vector gen/int)]
(let [result (myapp.sort/quicksort v)]
(every? (fn [[a b]] (<= a b))
(partition 2 1 result))))]
(is (:result (tc/quick-check 500 prop-sorted))))))
6. Separate Unit, Integration, and Property Tests
Use metadata to categorize tests and run them selectively:
(deftest ^:unit pure-function-test
...)
(deftest ^:integration api-endpoint-test
...)
(deftest ^:property generative-test
...)
(deftest ^:slow load-test
...)
Then run specific categories:
# Run only unit tests
lein test :unit
# Run everything except slow tests
lein test :no-slow
7. Test at the Boundaries
Focus your testing effort on the boundaries of your system — external APIs, database queries, user input parsing:
(deftest boundary-test
(testing "external API response parsing"
(with-redefs [http/get (fn [_] {:body "{\"temp\": 25.3, \"humidity\": 60}")]
(is (= {:temp 25.3 :humidity 60}
(myapp.weather/parse-response (http/get "fake-url"))))))
(testing "malformed API response"
(with-redefs [http/get (fn [_] {:body "not json"})]
(is (thrown? Exception (myapp.weather/parse-response (http/get "fake-url"))))))
(testing "database query with no results"
(is (= [] (myapp.db/find-users-by-status :nonexistent-status)))))
8. Use Test Selectors for CI vs Local Development
;; project.clj
:test-selectors {:default (complement :integration)
:integration :integration
:all (constantly true)}
# Fast feedback loop locally
lein test
# Full suite in CI
lein test :all
Advanced: Mocking with Mockito
For Java interop scenarios where with-redefs isn't sufficient, you can use Mockito through circle/mock or similar libraries:
(ns myapp.java-interop-test
(:require [clojure.test :refer :all])
(:import [java.net HttpURLConnection URL]
[java.io ByteArrayInputStream]))
(deftest java-mock-test
(testing "mocking Java HTTP connection"
(let [mock-conn (proxy [HttpURLConnection] [(URL. "http://fake")]
(getResponseCode [] 200)
(getInputStream []
(ByteArrayInputStream.
(.getBytes "{\"status\": \"ok\"}"))))]
(with-redefs [myapp.service/create-connection (fn [_] mock-conn)]
(is (= {:status "ok"} (myapp.service/fetch-status)))))))
Code Coverage
Use cloverage to measure test coverage and identify untested code paths:
# Add to project.clj
:profiles {:dev {:dependencies [[cloverage "1.2.4"]]}}
# Run coverage
lein cloverage --runner clojure.test
# With namespace filters
lein cloverage --ns-exclude-regex ".*\.dev"
Review coverage reports to find gaps, but remember that 100% coverage doesn't guarantee quality — focus on meaningful tests of behavior.
Conclusion
Testing in Clojure benefits greatly from the language's functional nature: pure functions are trivially testable, immutability eliminates entire classes of state-related bugs, and the REPL enables rapid test-driven development. By combining clojure.test for unit tests, property-based testing with test.check for algorithmic correctness, integration tests for boundary verification, and thoughtful fixture management, you can build a robust testing strategy that scales with your application. Remember that the goal isn't simply to achieve high coverage numbers, but to write tests that document your intent, catch regressions, and give you confidence to refactor aggressively. Start with pure function tests, add property tests for algorithms, and layer in integration tests at the boundaries — this layered approach will serve your Clojure applications well as they grow in complexity.