ClojureScript Testing with shadow-cljs and cljs.test

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 cljsjs wrappers)
  • 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-cljs

Basic 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.js

Or use the watch command for continuous testing:

npx shadow-cljs watch test

Watch 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.js

Async 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:8021

The 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.js

Common 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/testing API as Clojure
  • Node.js target for fast unit tests without a browser
  • Async test support with the done callback 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.

Read more