ClojureScript Testing with shadow-cljs and cljs.test
ClojureScript brings Clojure's testing model to the browser and Node.js. The testing primitives — deftest, is, testing — work identically to Clojure. But the runtime environment is different: no JVM, different async semantics, and multiple possible test targets. shadow-cljs makes all of this manageable with a single configuration file.
shadow-cljs vs figwheel-main
Both shadow-cljs and figwheel-main support ClojureScript testing, but shadow-cljs has become the dominant choice:
- Better npm interop (no need for
cljsjswrappers) - Faster incremental compilation
- Built-in test runner configuration
- Better IDE tooling integration
This guide uses shadow-cljs throughout.
Project Setup
Install shadow-cljs:
npm install --save-dev shadow-cljsBasic shadow-cljs.edn:
{:source-paths ["src" "test"]
:dependencies [[reagent "1.2.0"]
[re-frame "1.3.0"]]
:builds {:app {:target :browser
:output-dir "public/js"
:asset-path "/js"
:modules {:main {:init-fn myapp.core/init}}}
:test {:target :node-test
:output-to "out/node-tests.js"
:ns-regexp "-test$"}}}The :test build targets Node.js — fast, no browser required, good for unit tests. We'll cover browser testing later.
Writing cljs.test Tests
The API is identical to clojure.test:
;; test/myapp/core_test.cljs
(ns myapp.core-test
(:require [cljs.test :refer [deftest is testing async]]
[myapp.core :refer [add-item remove-item count-items format-price]]))
(deftest add-item-test
(testing "adds item to empty collection"
(is (= 1 (count-items (add-item [] {:id 1 :name "Widget"})))))
(testing "adds item to existing collection"
(let [cart (add-item [] {:id 1 :name "Widget"})
cart (add-item cart {:id 2 :name "Gadget"})]
(is (= 2 (count-items cart))))))
(deftest remove-item-test
(testing "removes item by id"
(let [cart (-> [] (add-item {:id 1}) (add-item {:id 2}))]
(is (= 1 (count-items (remove-item cart 1))))))
(testing "no-op when item not found"
(let [cart (add-item [] {:id 1})]
(is (= 1 (count-items (remove-item cart 99)))))))
(deftest format-price-test
(is (= "$10.00" (format-price 10)))
(is (= "$10.99" (format-price 10.99)))
(is (= "$0.00" (format-price 0))))Running Tests
Run the test build with shadow-cljs:
npx shadow-cljs compile test
node out/node-tests.jsOr use the watch command for continuous testing:
npx shadow-cljs watch testWatch mode recompiles on file changes and re-runs tests automatically.
For a single run that exits with the appropriate code (for CI):
npx shadow-cljs compile test && node out/node-tests.jsAsync Tests
Browser code is full of async operations — Promises, callbacks, timers. cljs.test handles async with the async macro:
(ns myapp.api-test
(:require [cljs.test :refer [deftest is testing async]]
[myapp.api :as api]))
(deftest fetch-user-test
(async done
(-> (api/fetch-user 123)
(.then (fn [user]
(is (= 123 (:id user)))
(is (string? (:name user)))
(done)))
(.catch (fn [err]
(is (nil? err) "Should not have errored")
(done))))))The done callback signals test completion. You must call it — if you don't, the test runner waits indefinitely.
With core.async, use channels:
(ns myapp.websocket-test
(:require [cljs.test :refer [deftest is async]]
[cljs.core.async :as a :refer [go <!]]
[myapp.websocket :as ws]))
(deftest message-roundtrip-test
(async done
(go
(let [conn (ws/connect "ws://localhost:8080")
result (<! (ws/send-and-wait conn {:type :ping}))]
(is (= :pong (:type result)))
(ws/disconnect conn)
(done)))))Mocking JavaScript APIs
JavaScript globals and browser APIs need mocking in unit tests. Use with-redefs for ClojureScript functions and direct assignment for JavaScript globals:
(ns myapp.storage-test
(:require [cljs.test :refer [deftest is testing use-fixtures]]
[myapp.storage :refer [save-preference load-preference]]))
;; Mock localStorage
(def mock-storage (atom {}))
(defn setup-mock-storage! []
(set! js/localStorage
(js-obj "getItem" (fn [k] (get @mock-storage k))
"setItem" (fn [k v] (swap! mock-storage assoc k v))
"removeItem" (fn [k] (swap! mock-storage dissoc k))
"clear" (fn [] (reset! mock-storage {})))))
(use-fixtures :each
{:before (fn []
(reset! mock-storage {})
(setup-mock-storage!))})
(deftest save-preference-test
(testing "persists preference to storage"
(save-preference :theme "dark")
(is (= "dark" (load-preference :theme)))))
(deftest load-preference-test
(testing "returns nil for missing key"
(is (nil? (load-preference :nonexistent))))
(testing "returns stored value"
(save-preference :language "en")
(is (= "en" (load-preference :language)))))For fetch and HTTP:
(ns myapp.http-test
(:require [cljs.test :refer [deftest is async]]
[myapp.http :refer [get-user]]))
(deftest get-user-test
(async done
(let [mock-response (js/Promise.resolve
(js-obj "ok" true
"json" (fn []
(js/Promise.resolve
(clj->js {:id 1 :name "Alice"})))))]
(with-redefs [js/fetch (fn [& _] mock-response)]
(-> (get-user 1)
(.then (fn [user]
(is (= 1 (:id user)))
(done))))))))Testing Reagent Components
For Reagent components, use reagent.core/render with a DOM element:
(ns myapp.components-test
(:require [cljs.test :refer [deftest is testing use-fixtures async]]
[reagent.core :as r]
[reagent.dom :as rdom]
[myapp.components :refer [counter-component]]))
(def container (atom nil))
(use-fixtures :each
{:before (fn []
(let [div (.createElement js/document "div")]
(.appendChild (.-body js/document) div)
(reset! container div)))
:after (fn []
(rdom/unmount-component-at-node @container)
(.removeChild (.-body js/document) @container))})
(deftest counter-component-test
(testing "renders initial count"
(rdom/render [counter-component {:initial 0}] @container)
(r/flush)
(is (= "0" (.-textContent (.querySelector @container ".count")))))
(testing "increments on button click"
(rdom/render [counter-component {:initial 0}] @container)
(r/flush)
(.click (.querySelector @container "button.increment"))
(r/flush)
(is (= "1" (.-textContent (.querySelector @container ".count"))))))r/flush forces Reagent to process all pending state updates synchronously — required when testing component state changes.
Browser Test Target
For tests requiring real browser APIs (Canvas, WebGL, CSS layout), use the :browser-test target:
;; shadow-cljs.edn
{:builds {:browser-test {:target :browser-test
:test-dir "out/browser-tests"
:ns-regexp "-test$"
:runner-ns shadow.test.browser}}}Start a dev server and open the test runner:
npx shadow-cljs watch browser-test
open http://localhost:8021The browser test page shows test results live as files change.
For CI with a headless browser, use Karma or Playwright with the :karma target:
{:builds {:karma-test {:target :karma
:output-to "out/karma-tests.js"
:ns-regexp "-test$"}}}// karma.conf.js
module.exports = function(config) {
config.set({
frameworks: ['cljs-test'],
files: ['out/karma-tests.js'],
reporters: ['progress'],
port: 9876,
browsers: ['ChromeHeadless'],
singleRun: true
});
};Test Namespaces and Discovery
shadow-cljs discovers test namespaces by matching against :ns-regexp. The default is -test$, matching any namespace ending in -test. Configure it to match your conventions:
{:builds {:test {:target :node-test
:output-to "out/node-tests.js"
:ns-regexp "test\\.myapp\\..*-test$"}}}For explicit control, list namespaces directly:
{:builds {:test {:target :node-test
:output-to "out/node-tests.js"
:autorun true
:entries [myapp.core-test
myapp.api-test
myapp.components-test]}}}CI Pipeline
name: Test
on: [push, pull_request]
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- uses: actions/setup-node@v3
with:
node-version: 18
cache: npm
- name: Install dependencies
run: npm ci
- name: Compile tests
run: npx shadow-cljs compile test
- name: Run tests
run: node out/node-tests.jsCommon Pitfalls
Missing done callback — async tests that never call done hang the test runner indefinitely. Always call done in both success and error paths.
Reagent state leaking between tests — if you modify app state in one test, it bleeds into the next. Use use-fixtures to reset state before each test.
Testing re-frame event handlers — use re-frame.core/reg-event-db effects directly, not by dispatching events, so tests don't depend on async dispatch timing.
js/console noise — tests that log errors through js/console.error make test output hard to read. Mock js/console.error in tests that expect error conditions.
Summary
ClojureScript testing with shadow-cljs and cljs.test provides:
- The same
deftest/is/testingAPI as Clojure - Node.js target for fast unit tests without a browser
- Async test support with the
donecallback pattern - Browser test target for DOM-dependent tests
- Watch mode for continuous feedback during development
The combination of shadow-cljs's fast compilation and cljs.test's familiar API makes ClojureScript testing approachable for developers coming from both Clojure and JavaScript backgrounds.