ClojureScript Testing with shadow-cljs: Browser and Node Test Runners

ClojureScript Testing with shadow-cljs: Browser and Node Test Runners

ClojureScript brings Clojure's expressiveness to the browser and Node.js, but testing it presents unique challenges: you need to compile to JavaScript first, deal with the browser's asynchronous nature, and manage JavaScript interop. shadow-cljs makes all of this manageable. This guide covers setting up test runners in both Node.js and the browser, testing async code correctly, mocking JavaScript interop, and getting it all running in CI.

Project Setup with shadow-cljs

Start with a deps.edn project:

;; deps.edn
{:paths ["src" "test"]
 :deps {org.clojure/clojure {:mvn/version "1.11.1"}
        org.clojure/clojurescript {:mvn/version "1.11.60"}
        thheller/shadow-cljs {:mvn/version "2.27.3"}}}

The shadow-cljs configuration lives in shadow-cljs.edn:

;; shadow-cljs.edn
{:source-paths ["src" "test"]

 :dependencies
 [[reagent "1.2.0"]
  [cljs-ajax "0.8.4"]]

 :builds
 {:app {:target :browser
        :output-dir "public/js"
        :asset-path "/js"
        :modules {:main {:init-fn myapp.core/init}}}

  ;; Node.js test runner
  :test {:target :node-test
         :output-to "out/node-tests.js"
         :main myapp.test-runner/main}

  ;; Browser test runner
  :browser-test {:target :browser-test
                 :test-dir "out/browser-tests"
                 :asset-path "/browser-tests"
                 :ns-regexp "-test$"}}}

Install dependencies and start the dev server:

npx shadow-cljs watch app
npx shadow-cljs compile test  # compile once for CI
npx shadow-cljs watch test    # watch mode for development

Writing Tests with cljs.test

ClojureScript's cljs.test mirrors clojure.test closely:

(ns myapp.core-test
  (:require [cljs.test :refer-macros [deftest is testing run-tests]]
            [myapp.core :as core]))

(deftest addition-test
  (is (= 4 (core/add 2 2))))

(deftest string-processing-test
  (testing "uppercase conversion"
    (is (= "HELLO" (core/process-string "hello"))))
  (testing "empty string"
    (is (= "" (core/process-string "")))))

Note refer-macros instead of refer — ClojureScript requires this for macros since they run at compile time on the JVM.

Configuring the Node.js Test Runner

The :node-test target runs tests in Node.js, which is faster than launching a browser and works well in CI:

;; shadow-cljs.edn
{:builds
 {:test {:target :node-test
         :output-to "out/node-tests.js"
         :main myapp.test-runner/main
         :ns-regexp "-test$"    ;; auto-discover test namespaces
         :runner-ns myapp.test-runner}}}

Create the test runner namespace:

(ns myapp.test-runner
  (:require [cljs.test :as test]
            ;; require all test namespaces
            myapp.core-test
            myapp.utils-test
            myapp.api-test))

(defn main []
  (test/run-all-tests #"myapp\..*-test"))

For automatic namespace discovery with :ns-regexp, shadow-cljs generates the require list for you. Just define the runner entry point:

(ns myapp.test-runner
  (:require [shadow.test.node :as node]))

(defn main []
  (node/main))

Run the tests:

# Compile and run once
npx shadow-cljs compile test && node out/node-tests.js

# Watch and auto-rerun
npx shadow-cljs watch test --config-merge '{:devtools {:autoload true}}'

Browser-Based Tests

For code that uses browser APIs (DOM, localStorage, fetch), you need a browser test runner.

shadow-cljs Browser Test Target

;; shadow-cljs.edn
{:builds
 {:browser-test {:target :browser-test
                 :test-dir "out/browser-tests"
                 :asset-path "/browser-tests"
                 :ns-regexp "-test$"
                 :runner-ns shadow.test.browser}}}

shadow-cljs generates an index.html in :test-dir that loads and runs all tests. Start a watch and open the browser:

npx shadow-cljs watch browser-test
# Open http://localhost:8021/browser-tests/index.html

Test results appear in the browser console and on the page.

Karma for CI

For headless browser testing in CI, use Karma with shadow-cljs:

npm install --save-dev karma karma-chrome-launcher karma-cljs-test
// karma.conf.js
module.exports = function(config) {
  config.set({
    browsers: ['ChromeHeadless'],
    basePath: 'out/browser-tests',
    files: [
      {pattern: '**', included: false, watched: true, served: true}
    ],
    frameworks: ['cljs-test'],
    plugins: ['karma-cljs-test', 'karma-chrome-launcher'],
    client: {
      args: ['shadow.test.karma.init'],
      singleRun: true
    }
  });
};
;; shadow-cljs.edn — add karma config
{:builds
 {:karma {:target :karma
          :output-to "out/karma/tests.js"
          :ns-regexp "-test$"}}}

Run in CI:

npx shadow-cljs compile karma
npx karma start --single-run

Testing Async Code with cljs.test/async

Asynchronous testing is where ClojureScript tests diverge from Clojure. You must explicitly tell cljs.test when an async test has finished.

Basic Async Pattern

(ns myapp.async-test
  (:require [cljs.test :refer-macros [deftest is async]]
            [cljs.core.async :as a]
            [myapp.api :as api]))

(deftest fetch-user-test
  (async done
    ;; done is a callback you must call when finished
    (api/fetch-user "123"
      (fn [user]
        (is (= "Alice" (:name user)))
        (is (uuid? (:id user)))
        (done)))))

The async macro sets up the test to wait for done to be called. If done is never called, the test framework eventually times out.

Promises with js/Promise

Modern ClojureScript often works with JavaScript Promises:

(deftest fetch-data-promise-test
  (async done
    (-> (api/fetch-data {:id 42})
        (.then (fn [data]
                 (is (= 42 (:id data)))
                 (is (string? (:name data)))))
        (.then done)
        (.catch (fn [err]
                  (is false (str "Request failed: " err))
                  (done))))))

Always attach a .catch handler. Without it, a rejected promise silently swallows failures.

core.async Integration

If your code uses core.async:

(ns myapp.channel-test
  (:require [cljs.test :refer-macros [deftest is async]]
            [cljs.core.async :refer [go <!]]
            [myapp.processor :as proc]))

(deftest process-events-test
  (async done
    (go
      (let [result (<! (proc/process-async {:type :compute :value 10}))]
        (is (= 20 (:result result)))
        (is (= :success (:status result)))
        (done)))))

The go block is started but control returns immediately. When the go block completes, it calls done.

Testing with promesa

The promesa library makes async code more ergonomic:

(ns myapp.promesa-test
  (:require [cljs.test :refer-macros [deftest is async]]
            [promesa.core :as p]
            [myapp.service :as svc]))

(deftest service-roundtrip-test
  (async done
    (-> (p/let [item  (svc/create! {:name "Widget"})
                found (svc/find-by-id (:id item))]
          (is (= "Widget" (:name found)))
          (is (= (:id item) (:id found))))
        (p/catch (fn [e]
                   (is false (str "Failed: " (.-message e)))))
        (p/finally done))))

Mocking JavaScript Interop

Testing code that calls JavaScript APIs requires mocking those APIs.

with-redefs for ClojureScript Functions

with-redefs works the same as in Clojure for ClojureScript vars:

(ns myapp.storage-test
  (:require [cljs.test :refer-macros [deftest is]]
            [myapp.storage :as storage]))

(deftest save-session-test
  (let [stored (atom {})
        mock-storage (clj->js {:setItem (fn [k v] (swap! stored assoc k v))
                               :getItem (fn [k] (@stored k))
                               :removeItem (fn [k] (swap! stored dissoc k))})]
    (with-redefs [js/localStorage mock-storage]
      (storage/save-session "abc123")
      (is (= "abc123" (.getItem mock-storage "session"))))))

Mocking JS Modules

For code that imports JavaScript modules via require or ES imports:

(ns myapp.http-test
  (:require [cljs.test :refer-macros [deftest is async]]
            [myapp.http :as http]))

(defn make-mock-fetch [responses]
  (let [calls (atom [])]
    {:mock-fn (fn [url opts]
                (swap! calls conj {:url url :opts opts})
                (js/Promise.resolve
                  (clj->js {:ok true
                             :status 200
                             :json (fn [] (js/Promise.resolve
                                           (clj->js (get responses url {}))))})))
     :calls calls}))

(deftest http-get-test
  (async done
    (let [{:keys [mock-fn calls]} (make-mock-fetch
                                    {"/api/users/1" {:id 1 :name "Alice"}})]
      (with-redefs [js/fetch mock-fn]
        (-> (http/get "/api/users/1")
            (.then (fn [user]
                     (is (= 1 (:id user)))
                     (is (= "/api/users/1" (:url (first @calls))))
                     (done))))))))

Using sinon.js for Complex Mocking

npm install --save-dev sinon
(ns myapp.timer-test
  (:require [cljs.test :refer-macros [deftest is]]
            ["sinon" :as sinon]
            [myapp.scheduler :as sched]))

(deftest delayed-action-test
  (let [clock (.useFakeTimers sinon)]
    (try
      (let [calls (atom [])]
        (sched/schedule-after 1000 #(swap! calls conj :fired))
        (is (= [] @calls) "Not fired yet")
        (.tick clock 1001)
        (is (= [:fired] @calls) "Fired after tick"))
      (finally
        (.restore clock)))))

Organizing ClojureScript Tests

src/
  myapp/
    core.cljs
    api.cljs
    ui/
      components.cljs
test/
  myapp/
    core_test.cljs
    api_test.cljs
    ui/
      components_test.cljs
  myapp/
    test_runner.cljs

Shared test utilities:

(ns myapp.test.helpers
  (:require [cljs.test :refer-macros [is]]))

(defn assert-eventually
  "Poll a predicate for up to timeout-ms, calling done-fn when it passes or fails."
  [pred timeout-ms done-fn]
  (let [start (js/Date.now)]
    (letfn [(check []
              (cond
                (pred) (done-fn)
                (> (- (js/Date.now) start) timeout-ms)
                  (do (is false "Timed out waiting for condition")
                      (done-fn))
                :else (.setTimeout js/window check 50)))]
      (check))))

(defn mock-event [type props]
  (let [event (js/Event. type (clj->js {:bubbles true}))]
    (doseq [[k v] props]
      (aset event (name k) v))
    event))

CI Setup with GitHub Actions

# .github/workflows/test.yml
name: ClojureScript Tests

on: [push, pull_request]

jobs:
  test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4

      - name: Setup Java
        uses: actions/setup-java@v4
        with:
          java-version: '21'
          distribution: 'temurin'

      - name: Setup Node.js
        uses: actions/setup-node@v4
        with:
          node-version: '20'
          cache: 'npm'

      - name: Install npm dependencies
        run: npm ci

      - name: Install Clojure CLI
        uses: DeLaGuardo/setup-clojure@12
        with:
          cli: 1.11.1.1413

      - name: Cache Maven
        uses: actions/cache@v4
        with:
          path: ~/.m2/repository
          key: ${{ runner.os }}-maven-${{ hashFiles('**/deps.edn') }}

      - name: Compile tests
        run: npx shadow-cljs compile test

      - name: Run Node.js tests
        run: node out/node-tests.js

      - name: Run browser tests (headless)
        run: |
          npx shadow-cljs compile karma
          npx karma start --single-run

Debugging Failing Tests

shadow-cljs provides source maps, so stack traces point to your ClojureScript source:

# Run with source maps enabled
NODE_PATH=out node --enable-source-maps out/node-tests.js

To print intermediate values in tests:

(deftest complex-transform-test
  (let [input {:data [1 2 3 4 5]}
        result (transform input)]
    (js/console.log "result:" (clj->js result))
    (is (= [2 4 6 8 10] (:processed result)))))

ClojureScript testing with shadow-cljs is mature and reliable. The Node.js runner handles the majority of business logic tests fast; the browser runner catches DOM and browser-API-dependent code. The async testing model requires discipline — always call done, always attach error handlers — but once internalized, it handles real-world async code well.

Read more

Start now free