Version: 1.0.0
Status: Production Ready
Last Updated: 2026-01-26
Comprehensive guide to testing Boundary applications, covering the three-tier testing strategy, metadata usage, snapshot testing, and best practices.
Boundary follows the Functional Core / Imperative Shell (FC/IS) architecture. This design significantly impacts how we test our code:
core/ layer. These are tested with simple unit tests.shell/ layer. We use integration and contract tests to verify these interactions.We aim for a classic test pyramid:
We categorize tests into three distinct tiers to balance speed, isolation, and confidence.
| Tier | Focus | Location | Metadata | Dependencies |
|---|---|---|---|---|
| Unit | Pure logic, transformations | core/*_test.clj | :unit | None |
| Integration | Service orchestration | shell/*_test.clj | :integration | H2 DB / Mocked Ports |
| Contract | HTTP/Persistence boundaries | shell/*_test.clj | :contract | Real DB (H2) + HTTP |
Unit tests focus on the Functional Core. They verify that pure functions produce the correct output for a given input.
Testing a pure validation function in boundary.user.core.user:
(ns boundary.user.core.user-test
(:require [boundary.user.core.user :as user-core]
[clojure.test :refer [deftest is testing]]))
^{:kaocha.testable/meta {:unit true}}
(deftest validate-user-creation-request-test
(testing "Valid request passes"
(let [request {:email "alice@example.com" :name "Alice" :role :user}
[valid? errors data] (user-core/validate-user-creation-request request)]
(is (true? valid?))
(is (empty? errors))
(is (= "alice@example.com" (:email data)))))
(testing "Invalid email fails"
(let [request {:email "not-an-email" :name "Alice" :role :user}
[valid? errors] (user-core/validate-user-creation-request request)]
(is (false? valid?))
(is (contains? errors :email)))))
Testing a Hiccup-generating function. Notice we assert on the data structure first, which is more robust than string matching.
(deftest render-user-badge-test
(testing "Renders admin badge"
(let [user {:name "Admin" :role :admin}
result (ui/render-user-badge user)]
;; Structural assertion
(is (= :span.badge.admin (first result)))
;; Content assertion
(is (clojure.string/includes? (str result) "Administrator")))))
Testing the conversion between database format (snake_case) and internal format (kebab-case):
(deftest entity-conversion-test
(testing "Converts snake_case DB record to kebab-case entity"
(let [db-record {:first_name "John" :last_name "Doe" :created_at #inst "2025-01-01"}
expected {:first-name "John" :last-name "Doe" :created-at #inst "2025-01-01"}]
(is (= expected (utils/db->entity db-record))))))
Integration tests verify the Imperative Shell. They ensure that service functions correctly coordinate between the Functional Core and external ports (adapters).
Testing boundary.user.shell.service using a real H2 database. We use a dynamic variable *service* to hold the initialized service instance.
(ns boundary.user.shell.service-test
(:require [boundary.user.ports :as ports]
[boundary.user.shell.service :refer [create-service]]
[boundary.platform.shell.adapters.database.factory :as db-factory]
[clojure.test :refer [deftest is testing use-fixtures]]))
^{:kaocha.testable/meta {:integration true}}
(defonce ^:dynamic *db-ctx* nil)
(defonce ^:dynamic *service* nil)
(defn setup-test-db [f]
(let [db-ctx (db-factory/db-context {:adapter :h2 :database-path "mem:test_db"})]
;; Run migrations or create tables manually for the test
(db-factory/execute! db-ctx ["CREATE TABLE users (...)"])
(binding [*db-ctx* db-ctx
*service* (create-service db-ctx)]
(f)
(db-factory/close-db-context! db-ctx))))
(use-fixtures :once setup-test-db)
(deftest register-user-integration-test
(testing "Successfully registers user in database"
(let [user-data {:email "new@example.com" :name "New User" :password "pass123"}
result (ports/register-user *service* user-data)]
(is (uuid? (:id result)))
(is (= "new@example.com" (:email result)))
;; Verify persistence
(let [persisted (ports/get-user-by-email *service* "new@example.com")]
(is (= (:id result) (:id persisted)))))))
Contract tests verify the system boundaries. They ensure that our HTTP endpoints and database adapters adhere to the expected interface (contracts).
Testing the login endpoint using Ring requests. We bypass the actual network and call the handler function directly.
(deftest login-endpoint-test
(testing "Successful login returns 200 and JWT token"
(let [request {:request-method :post
:uri "/api/auth/login"
:body (json/generate-string {:email "alice@example.com"
:password "correct-pass"})
:headers {"content-type" "application/json"}}
response (*handler* request)]
(is (= 200 (:status response)))
(is (contains? (json/parse-string (:body response)) "token"))))
(testing "Invalid credentials return 401"
(let [request {:request-method :post
:uri "/api/auth/login"
:body (json/generate-string {:email "alice@example.com"
:password "wrong-pass"})
:headers {"content-type" "application/json"}}
response (*handler* request)]
(is (= 401 (:status response))))))
Tests are organized to mirror the src directory structure within each library. This makes it easy to find the corresponding test for any source file.
libs/{library}/
├── src/boundary/{library}/
│ ├── core/ # Pure logic
│ └── shell/ # I/O, adapters
└── test/boundary/{library}/
├── core/ # Unit tests
└── shell/ # Integration & Contract tests
We use metadata tags to allow the test runner to filter tests by layer or module.
:unit: Pure functions, no I/O.:integration: Service layer tests with database.:contract: Boundary tests (HTTP/persistence).:user, :admin, :core, etc.Applying metadata:
;; At the namespace level (Preferred for layer filtering)
(ns boundary.user.core.user-test)
(alter-meta! *ns* assoc :kaocha/tags [:unit :user])
;; At the individual test level (For specific test filtering)
(deftest ^{:unit true} my-test ...)
The framework uses Kaocha as the test runner. All commands should be run from the root directory.
# Run all tests across all libraries
clojure -M:test:db/h2
# Run all tests with JWT secret (required for some auth tests)
JWT_SECRET="dev-secret-32-chars-minimum" clojure -M:test:db/h2
Metadata filtering allows you to run only the relevant subset of tests during development.
# Run unit tests only (fastest)
clojure -M:test:db/h2 --focus-meta :unit
# Run integration tests
clojure -M:test:db/h2 --focus-meta :integration
# Run contract tests
clojure -M:test:db/h2 --focus-meta :contract
# Run tests for specific library
clojure -M:test:db/h2 :core
clojure -M:test:db/h2 :user
clojure -M:test:db/h2 :admin
Watch mode automatically re-runs tests when files are saved. Highly recommended for TDD.
# Watch core library unit tests
clojure -M:test:db/h2 --watch :core --focus-meta :unit
# Watch all user module tests
clojure -M:test:db/h2 --watch :user
Snapshot testing is used to ensure that complex data structures (like validation results or HTML fragments) remain stable.
Validation rules can become complex. Snapshot testing captures the entire result (including error messages and data shapes) and flags any deviation. This is much more effective than manually asserting on individual error strings.
snapshot-io/check-snapshot!.# Update validation snapshots for the user module
UPDATE_SNAPSHOTS=true clojure -M:test:db/h2 --focus boundary.user.core.user-validation-snapshot-test
(deftest email-validation-invalid-format-snapshot
(testing "Invalid email format produces structured error"
(let [request (assoc valid-user-request :email "not-an-email")
result (user-core/validate-user-creation-request request)]
(snapshot-io/check-snapshot!
result
{:ns (ns-name *ns*)
:test 'email-validation-invalid-format}))))
Snapshots are stored as .edn files under libs/{library}/test/snapshots/validation/. They are human-readable and should be committed to version control.
We use dynamic vars and use-fixtures to manage test setup (like database connections). This avoids global state pollution.
(defonce ^:dynamic *db-ctx* nil)
(defn with-clean-database [f]
(let [ds (get-in *db-ctx* [:datasource])]
(jdbc/execute! ds ["DELETE FROM users"])
(f)))
(use-fixtures :each with-clean-database)
Common operations should be extracted to helper functions to keep tests readable and maintainable.
(defn create-test-user! [email role]
(ports/create-user *user-service* {:email email :role role :password "pass123"}))
(defn authenticated-request [method uri user]
(let [token (auth/generate-token user)]
{:request-method method
:uri uri
:headers {"authorization" (str "Bearer " token)}}))
Boundary prioritizes accessibility in its UI. We test for ARIA labels, semantic HTML, and form associations.
<label> with a for attribute.aria-label.*) and the required attribute.<nav> tags.aria-describedby or placement).(deftest icon-button-accessibility-test
(testing "Search button has aria-label"
(let [html (str (ui/icon-button :search))]
(is (clojure.string/includes? html "aria-label=\"Search\"")))))
(deftest form-field-label-test
(testing "Email input has associated label"
(let [html (str (ui/render-email-field :email "test@example.com"))]
(is (clojure.string/includes? html "<label for=\"email\">"))
(is (clojure.string/includes? html "<input id=\"email\" type=\"email\"")))))
Since Boundary uses HTMX for dynamic behavior, we must test that our handlers return the correct fragments and headers.
Verify that a fragment handler returns only the partial HTML, not the full page layout.
(deftest table-fragment-test
(testing "Returns only the table container"
(let [request {:headers {"hx-request" "true"}}
response (handlers/table-fragment request)]
(is (not (clojure.string/includes? (:body response) "<body")))
(is (clojure.string/includes? (:body response) "id=\"entity-table\"")))))
Verify that the server sends HX-Trigger or HX-Redirect headers when appropriate.
(deftest user-creation-htmx-test
(testing "Sends HX-Trigger header on success"
(let [request {:request-method :post :form-params {...}}
response (handlers/create-user-handler request)]
(is (= "userCreated" (get-in response [:headers "HX-Trigger"]))))))
While we prefer real H2 databases for integration tests, some external services must be mocked.
reify (Ad-hoc Mocks)Best for simple, one-off mocks within a single test file.
(let [mock-email-port (reify ports/IEmailPort
(send-email [_ details]
(reset! sent-emails-atom details)))]
(ports/register-user service data mock-email-port))
defrecord (Reusable Mocks)Best for mocks that are shared across multiple test files.
(defrecord MockStoragePort [files]
ports/IStoragePort
(upload-file [_ path content]
(swap! files assoc path content)))
(defn create-mock-storage []
(->MockStoragePort (atom {})))
We use cloverage to measure test coverage. Aim for 90%+ in core/ and 75%+ overall.
# Run coverage report for the user library
clojure -M:test:coverage :user
Tests are code too! Always lint your test directory.
clojure -M:clj-kondo --lint libs/user/test
clojure -M:test:db/h2 --watch :my-moduletest/boundary/my_module/core/feature_test.clj.src/boundary/my_module/core/feature.clj.:h2 alias for fast in-memory database testing.core/ layer.test-user-creation-with-invalid-email is better than test-1.def for test state: Use let or dynamic vars + binding.clj-kondo on your tests as well as your source code..env files.Many auth tests require a 32-character JWT secret.
export JWT_SECRET="test-secret-32-chars-minimum-length"
Ensure your integration tests are running migrations or creating the necessary schema in a :once fixture. Check your H2 connection string (e.g., mem:test;DB_CLOSE_DELAY=-1).
If your tests pass in the CLI but fail in the REPL (or vice versa), reset your system:
(integrant.repl/halt)
(integrant.repl/go)
If a snapshot test fails after an intentional change:
UPDATE_SNAPSHOTS=true.If you change a function signature in core/ or a protocol in ports.clj, you must update all calls in your tests. Use grep or your IDE's "Find Usages" to locate them.
Last updated: 2026-01-26 Documentation version: 1.0.0
Can you improve this documentation?Edit on GitHub
cljdoc builds & hosts documentation for Clojure/Script libraries
| Ctrl+k | Jump to recent docs |
| ← | Move to previous article |
| → | Move to next article |
| Ctrl+/ | Jump to the search field |