Testing Ring and Compojure Web Applications in Clojure

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 cleanup

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

Start now free