← Back to DevBytes

Testing Strategies for Clojure Applications

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:

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 testing blocks 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.

— Ad —

Google AdSense will appear here after approval

← Back to all articles