Testing Ring and Compojure Web Applications in Clojure
Clojure's web stack — Ring for HTTP abstraction, Compojure for routing — is highly testable because it's built around plain functions and immutable data. A Ring handler is just a function from request map to response map. This means you can test your entire HTTP layer without starting a server. This post covers the patterns for testing Ring middleware, Compojure routes, JSON APIs, and authentication — including database integration with both mount and component.
The Ring Request/Response Model
Ring's model is simple. A handler is a function:
(defn handler [request]
{:status 200
:headers {"Content-Type" "text/plain"}
:body "Hello, World!"})A middleware wraps a handler:
(defn wrap-logging [handler]
(fn [request]
(println "Request:" (:uri request))
(let [response (handler request)]
(println "Response status:" (:status response))
response)))Both are just functions. Tests call them directly.
Setting Up ring-mock
Add to deps.edn:
{:aliases
{:test {:extra-paths ["test"]
:extra-deps {ring/ring-mock {:mvn/version "0.4.0"}
ring/ring-core {:mvn/version "1.11.0"}
compojure/compojure {:mvn/version "1.7.1"}
cheshire/cheshire {:mvn/version "5.12.0"}}}}}ring-mock provides helpers to build request maps:
(ns myapp.routes-test
(:require [clojure.test :refer :all]
[ring.mock.request :as mock]
[myapp.handler :refer [app]]))
(deftest basic-request-test
(let [response (app (mock/request :get "/"))]
(is (= 200 (:status response)))))ring-mock Request Building
;; Basic requests
(mock/request :get "/users")
(mock/request :post "/users")
(mock/request :put "/users/1")
(mock/request :delete "/users/1")
;; Query parameters
(mock/request :get "/users" {:page "1" :per-page "20"})
;; => adds ?page=1&per-page=20 to URI
;; Form-encoded body (POST)
(-> (mock/request :post "/login")
(mock/content-type "application/x-www-form-urlencoded")
(mock/body "username=alice&password=secret"))
;; JSON body
(-> (mock/request :post "/users")
(mock/content-type "application/json")
(mock/json-body {:name "Alice" :email "alice@example.com"}))
;; Headers
(-> (mock/request :get "/protected")
(mock/header "Authorization" "Bearer token123"))
;; Cookies
(-> (mock/request :get "/dashboard")
(mock/cookie "session" "abc123"))Testing a Simple Handler
Start with the handler definition:
(ns myapp.handler
(:require [compojure.core :refer [defroutes GET POST PUT DELETE]]
[compojure.route :as route]
[ring.middleware.json :refer [wrap-json-body wrap-json-response]]
[ring.middleware.defaults :refer [wrap-defaults api-defaults]]
[myapp.db :as db]))
(defroutes app-routes
(GET "/" []
{:status 200 :body {:message "OK"}})
(GET "/users" []
{:status 200 :body (db/list-users)})
(GET "/users/:id" [id]
(if-let [user (db/get-user id)]
{:status 200 :body user}
{:status 404 :body {:error "Not found"}}))
(POST "/users" {body :body}
(let [user (db/create-user! body)]
{:status 201 :body user}))
(route/not-found {:status 404 :body {:error "Route not found"}}))
(def app
(-> app-routes
(wrap-json-body {:keywords? true})
wrap-json-response
(wrap-defaults api-defaults)))Tests:
(ns myapp.handler-test
(:require [clojure.test :refer :all]
[ring.mock.request :as mock]
[cheshire.core :as json]
[myapp.handler :refer [app]]
[myapp.test.db-helpers :refer [with-test-db reset-db!]]))
(use-fixtures :once with-test-db)
(use-fixtures :each (fn [f] (reset-db!) (f)))
(defn parse-body [response]
(json/parse-string (slurp (:body response)) true))
(deftest health-check-test
(let [response (app (mock/request :get "/"))]
(is (= 200 (:status response)))
(is (= "OK" (:message (parse-body response))))))
(deftest list-users-test
(let [response (app (mock/request :get "/users"))]
(is (= 200 (:status response)))
(is (vector? (:body (parse-body response))))))
(deftest get-user-found-test
(let [response (app (-> (mock/request :get "/users/1")))]
(is (= 200 (:status response)))
(is (= 1 (:id (parse-body response))))))
(deftest get-user-not-found-test
(let [response (app (mock/request :get "/users/99999"))]
(is (= 404 (:status response)))
(is (= "Not found" (:error (parse-body response))))))
(deftest create-user-test
(let [response (app (-> (mock/request :post "/users")
(mock/content-type "application/json")
(mock/json-body {:name "Charlie"
:email "charlie@example.com"})))]
(is (= 201 (:status response)))
(let [body (parse-body response)]
(is (= "Charlie" (:name body)))
(is (string? (:id body))))))
(deftest not-found-route-test
(let [response (app (mock/request :get "/nonexistent"))]
(is (= 404 (:status response)))))Testing Middleware
Test middleware in isolation by building minimal handler stacks:
(ns myapp.middleware-test
(:require [clojure.test :refer :all]
[ring.mock.request :as mock]
[myapp.middleware :refer [wrap-auth wrap-rate-limit wrap-correlation-id]]))
;; A minimal handler for testing middleware effects
(defn echo-handler [request]
{:status 200
:headers {}
:body request})
(deftest wrap-correlation-id-test
(let [handler (wrap-correlation-id echo-handler)
response (handler (mock/request :get "/"))]
(testing "adds X-Correlation-ID header to response"
(is (some? (get-in response [:headers "X-Correlation-ID"]))))
(testing "generates different IDs per request"
(let [id1 (get-in (handler (mock/request :get "/"))
[:headers "X-Correlation-ID"])
id2 (get-in (handler (mock/request :get "/"))
[:headers "X-Correlation-ID"])]
(is (not= id1 id2))))))
(deftest wrap-correlation-id-preserves-existing-test
(let [handler (wrap-correlation-id echo-handler)
req (-> (mock/request :get "/")
(mock/header "X-Correlation-ID" "existing-id"))
response (handler req)]
(is (= "existing-id"
(get-in response [:headers "X-Correlation-ID"])))))Testing Authentication Middleware
(ns myapp.auth-middleware-test
(:require [clojure.test :refer :all]
[ring.mock.request :as mock]
[myapp.middleware.auth :refer [wrap-jwt-auth]]
[myapp.jwt :as jwt]))
(def protected-handler
(wrap-jwt-auth
(fn [request]
{:status 200
:body {:user (:identity request)}})))
(deftest unauthenticated-request-test
(let [response (protected-handler (mock/request :get "/"))]
(is (= 401 (:status response)))))
(deftest invalid-token-test
(let [response (protected-handler
(-> (mock/request :get "/")
(mock/header "Authorization" "Bearer invalid.token.here")))]
(is (= 401 (:status response)))))
(deftest valid-token-test
(let [token (jwt/sign {:user-id "user-1" :role :admin} "test-secret")
response (protected-handler
(-> (mock/request :get "/")
(mock/header "Authorization" (str "Bearer " token))))]
(is (= 200 (:status response)))
(is (= "user-1" (get-in response [:body :user :user-id])))))
(deftest expired-token-test
(let [token (jwt/sign {:user-id "user-1"} "test-secret"
{:exp (-> (java.time.Instant/now)
(.minusSeconds 3600)
.getEpochSecond)})]
(let [response (protected-handler
(-> (mock/request :get "/")
(mock/header "Authorization" (str "Bearer " token))))]
(is (= 401 (:status response)))
(is (= "Token expired" (get-in response [:body :error]))))))JSON Request/Response Testing
Parsing JSON bodies in tests is repetitive. Extract helpers:
(ns myapp.test.http
(:require [cheshire.core :as json]
[ring.mock.request :as mock]))
(defn json-request [method path body]
(-> (mock/request method path)
(mock/content-type "application/json")
(mock/json-body body)))
(defn parse-json-body [response]
(let [body (:body response)]
(cond
(string? body) (json/parse-string body true)
(instance? java.io.InputStream body)
(json/parse-stream (java.io.InputStreamReader. body) true)
:else body)))
(defn assert-json-response [response status expected]
(clojure.test/is (= status (:status response)))
(clojure.test/is (= "application/json"
(get-in response [:headers "Content-Type"])))
(let [body (parse-json-body response)]
(doseq [[k v] expected]
(clojure.test/is (= v (get body k))
(str "Expected " k " to be " v " but got " (get body k))))))Use in tests:
(ns myapp.api-test
(:require [clojure.test :refer :all]
[myapp.test.http :refer [json-request parse-json-body assert-json-response]]
[myapp.handler :refer [app]]))
(deftest create-article-test
(let [response (app (json-request :post "/articles"
{:title "Test Article"
:content "Content here"
:tags ["clojure" "testing"]}))]
(assert-json-response response 201
{:title "Test Article"})
(is (string? (:id (parse-json-body response))))))Database Integration with Component
component manages lifecycle of stateful parts like database connections:
(ns myapp.system
(:require [com.stuartsierra.component :as component]
[next.jdbc :as jdbc]
[myapp.db :as db]))
(defrecord Database [spec conn]
component/Lifecycle
(start [this]
(let [c (jdbc/get-datasource spec)]
(assoc this :conn c)))
(stop [this]
(assoc this :conn nil)))
(defn new-database [spec]
(map->Database {:spec spec}))
(defrecord App [database handler]
component/Lifecycle
(start [this]
(assoc this :handler
(build-handler (:conn database))))
(stop [this]
(dissoc this :handler)))Test system:
(ns myapp.test.system
(:require [com.stuartsierra.component :as component]
[myapp.system :as sys]))
(def test-db-spec
{:dbtype "h2:mem" :dbname "testdb" :DB_CLOSE_DELAY "-1"})
(defn test-system []
(component/system-map
:database (sys/new-database test-db-spec)
:app (component/using
(sys/map->App {})
[:database])))
(defmacro with-system [[sym] & body]
`(let [system# (component/start (test-system))
~sym system#]
(try
~@body
(finally
(component/stop system#)))))In tests:
(deftest api-with-component-test
(with-system [system]
(let [handler (get-in system [:app :handler])
response (handler (mock/request :get "/users"))]
(is (= 200 (:status response))))))Database Integration with mount
mount is a simpler stateful resource manager:
(ns myapp.state
(:require [mount.core :refer [defstate]]
[next.jdbc :as jdbc]))
(defstate db
:start (jdbc/get-datasource (get-config :database))
:stop nil) ;; HikariCP pools handle cleanupFor tests, override the state before starting:
(ns myapp.test.helpers
(:require [clojure.test :refer :all]
[mount.core :as mount]
[myapp.state :refer [db]]
[myapp.db.migrations :as migrations]))
(defn with-test-mount [f]
(mount/start-with {#'db (jdbc/get-datasource
{:dbtype "h2:mem"
:dbname "testdb"
:DB_CLOSE_DELAY "-1"})})
(migrations/run-all! db)
(f)
(mount/stop))
(defn reset-test-data! []
(jdbc/execute! db ["DELETE FROM users"])
(jdbc/execute! db ["DELETE FROM orders"]))
(defn with-clean-db [f]
(reset-test-data!)
(f))(ns myapp.users-api-test
(:require [clojure.test :refer :all]
[ring.mock.request :as mock]
[myapp.test.helpers :refer [with-test-mount with-clean-db]]
[myapp.handler :refer [app]]))
(use-fixtures :once with-test-mount)
(use-fixtures :each with-clean-db)
(deftest user-crud-test
(testing "create and retrieve user"
(let [create-resp (app (-> (mock/request :post "/users")
(mock/json-body {:name "Alice"
:email "alice@example.com"})))]
(is (= 201 (:status create-resp)))
(let [user-id (:id (parse-json-body create-resp))
get-resp (app (mock/request :get (str "/users/" user-id)))]
(is (= 200 (:status get-resp)))
(is (= "Alice" (:name (parse-json-body get-resp))))))))Testing Compojure Route Matching
Test routes in isolation without middleware:
(ns myapp.routes-test
(:require [clojure.test :refer :all]
[ring.mock.request :as mock]
[myapp.routes :refer [app-routes]]))
;; Test bare routes without middleware wrapping
(deftest route-matching-test
(testing "GET /users/:id"
(let [req (mock/request :get "/users/42")
response (app-routes req)]
(is (some? response) "Route matched")
(is (not= 404 (:status response)))))
(testing "unknown routes return nil from defroutes"
(let [req (mock/request :get "/nonexistent")
response (app-routes req)]
;; defroutes returns nil for unmatched routes
;; wrap with route/not-found for 404 behavior
(is (nil? response)))))For testing route parameter extraction:
(deftest route-params-test
(let [captured-params (atom nil)
test-routes (compojure.core/routes
(compojure.core/GET "/items/:id/tags/:tag" [id tag]
(reset! captured-params {:id id :tag tag})
{:status 200 :body "ok"}))]
(test-routes (mock/request :get "/items/123/tags/featured"))
(is (= {:id "123" :tag "featured"} @captured-params))))Integration Test Structure
For end-to-end API tests, combine all pieces:
(ns myapp.integration-test
(:require [clojure.test :refer :all]
[ring.mock.request :as mock]
[myapp.handler :refer [app]]
[myapp.test.helpers :refer :all]
[myapp.test.http :refer :all]))
(use-fixtures :once with-test-db)
(use-fixtures :each with-clean-db)
(deftest full-user-workflow-test
;; 1. Create a user
(let [create-resp (app (json-request :post "/users"
{:name "Alice"
:email "alice@example.com"}))]
(is (= 201 (:status create-resp)))
(let [user (parse-json-body create-resp)
user-id (:id user)]
;; 2. Retrieve the user
(let [get-resp (app (mock/request :get (str "/users/" user-id)))]
(is (= 200 (:status get-resp)))
(is (= "Alice" (:name (parse-json-body get-resp)))))
;; 3. Update the user
(let [update-resp (app (json-request :put (str "/users/" user-id)
{:name "Alice Smith"}))]
(is (= 200 (:status update-resp)))
(is (= "Alice Smith" (:name (parse-json-body update-resp)))))
;; 4. Verify update persisted
(let [get-resp (app (mock/request :get (str "/users/" user-id)))]
(is (= "Alice Smith" (:name (parse-json-body get-resp)))))
;; 5. Delete the user
(let [del-resp (app (mock/request :delete (str "/users/" user-id)))]
(is (= 204 (:status del-resp))))
;; 6. Verify deletion
(let [get-resp (app (mock/request :get (str "/users/" user-id)))]
(is (= 404 (:status get-resp)))))))Ring's functional model makes web application testing in Clojure unusually clean. No test server, no HTTP client, no port management — just function calls returning maps. The patterns here scale from simple middleware tests to full CRUD workflows with real database state, all without leaving the REPL.