Testing re-frame code
Priority: event handlers (most logic lives there) > layer-3 subscription derivations > views (lowest bug yield; test via RTL or skip).
The golden pattern: dispatch in, subscribe out
Use day8.re-frame.test (day8.re-frame/test on Clojars, alias rf-test). It is a single .cljc namespace that works on both the JVM and ClojureScript — with handlers in .cljc files, the whole suite runs on the JVM (fast, no browser). Every test has the same three-step shape inside run-test-sync:
(ns app.cart.events-test
(:require [clojure.test :refer [deftest is testing]]
[day8.re-frame.test :as rf-test]
[app.cart.events] ;; side-effecting registrations
[app.cart.subs]
[re-frame.core :as rf]))
(deftest apply-coupon-test
(rf-test/run-test-sync
;; 1. SETUP — seed app-db by dispatching real events
(rf/dispatch [:cart/add-item {:id 1 :price 30}])
;; 2. EXECUTE — dispatch the event under test
(rf/dispatch [:cart/apply-coupon "SAVE10"])
;; 3. VERIFY — deref the subscriptions the UI would use
(is (= 27 @(rf/subscribe [:cart/total])))))
Why this shape:
- Events are the write contract, subscriptions the read contract. Tests don't depend on app-db's internal structure — the db shape can change freely as long as both contracts hold.
- No mocking of dispatch/subscribe. The same handlers production runs.
- Isolation is automatic.
run-test-syncsnapshots app-db and all handler registrations at entry (viamake-restore-fn) and rolls back on exit. Registrations made inside the block disappear afterwards.
Don't mutate re-frame.db/app-db directly. reset!/swap! seeding couples the test to exact db paths and bypasses validation; asserting via get-in @app-db couples it to the read side too. Every occurrence is a future breakage when the schema evolves. The one legitimate exception: a pure unit test of the handler fn itself, where a literal db is the test input:
(deftest add-item-unit-test
(let [db' (add-item {} [:cart/add-item {:id 1 :qty 2}])]
(is (= 2 (get-in db' [:cart :items 1 :qty])))))
run-test-sync semantics
Every dispatch (including dispatches made by handlers — cascades) is drained synchronously before control returns to your test, so @(subscribe ...) on the next line sees the new state. Exceptions thrown in handlers propagate directly into the test — a real advantage over async tests.
It cannot cope with genuinely async code (js/setTimeout, real AJAX) inside handlers. Instead, stub the effects with synchronous versions. On the JVM you can even register a reg-fx that invokes your real Ring handler for end-to-end tests.
Stubbing effects, coeffects, and downstream events
Register stubs inside run-test-sync so they roll back per test. A shared stub helper per feature keeps tests focused:
(defn stub-side-effects! []
(rf/reg-fx :local-storage/set (fn [_]))
(rf/reg-fx :dispatch-later (fn [_]))
;; cofx handlers with no request data are 1-arity; with inject-cofx data, 2-arity
(rf/reg-cofx :inst/now (fn [cofx] (assoc cofx :inst/now #inst "2026-01-01")))
(rf/reg-cofx :local-storage/get (fn [cofx _request] (assoc cofx :local-storage nil)))
;; stub downstream events other features own
(rf/reg-event-fx :navigation/reload (fn [_ _] {})))
Fixed coeffects (:inst/now above) make time-dependent handlers deterministic.
To assert what a handler produced, point a capturing stub at the effect or downstream event and assert on the capture:
(deftest fetch-plans-emits-http-request
(rf-test/run-test-sync
(stub-side-effects!)
(let [captured (atom nil)]
(rf/reg-fx :http-xhrio (fn [req] (reset! captured req)))
(rf/dispatch [:plans/fetch {:org-id 42}])
(is (= :get (:method @captured)))
(is (= "/api/plans" (:uri @captured))))))
(re-frame logs an "overwriting handler" warning on re-registration — accept it; it's signal.)
Don't deep-inspect effects maps. Asserting a handler returns exactly {:fx [[:dispatch [::a]] [:dispatch [::b]]]} couples the test to wiring. Stub ::a/::b to record the call, or assert the downstream outcome through a subscription — behaviour, not implementation.
For shared stubs used by a whole namespace, install once with a :once fixture (never :each — noisy warnings, wasted time; run-test-sync snapshots the post-stub state per test so overrides still roll back). Per-test overrides go inside run-test-sync.
JVM/kaocha gotcha: don't use a :each fixture calling (rf/make-restore-fn) — on the JVM, restoring mid-bookkeeping makes kaocha report "ran without assertions". run-test-sync's own wrapping is sufficient.
Async tests: run-test-async + wait-for
Only for genuinely async flows (HTTP callbacks, setTimeout, promises). If there's no async step, use run-test-sync — run-test-async does not make dispatch synchronous.
(deftest load-plans-async
(rf-test/run-test-async
(rf/dispatch-sync [:app/initialise]) ;; setup MUST use dispatch-sync
(rf/reg-fx :http-xhrio
(fn [{:keys [on-success]}]
(js/setTimeout #(rf/dispatch (conj on-success {:plans []})) 0)))
(rf/dispatch [:plans/fetch])
(rf-test/wait-for [:plans/fetch-succeeded :plans/fetch-failed] ;; [ok-ids failure-ids]
(is (= [] @(rf/subscribe [:plans/all]))))))
wait-for rules:
- Tail position only. Code lexically after
wait-forruns before the awaited event; all assertions go inside its body. Sequential async steps = nestedwait-for(gets ugly fast — preferrun-test-sync+ sync stubs whenever possible). - Binding vector is
[ids failure-ids event-sym].ids/failure-idsaccept a keyword, set/vector of keywords, or predicate. If afailure-idsevent fires first, the test fails. To destructure the event without failure-ids, pass explicitnil:(wait-for [:done nil [id arg]] ...). - It calls
(done)for you; state rollback is handled (plainmake-restore-fnfixtures can't clean up JS async tests — cleanup would run before the async code). - JVM: timeout is
rf-test/*test-timeout*(dynamic, default 5000 ms). A handler exception on the event thread surfaces only as a timeout (real error printed to console) — another reason to prefer sync tests.
Testing subscriptions
Layer 2 extractors: usually not worth a test — event tests exercise them implicitly. Layer 3 derivations: same golden pattern — seed via dispatch, assert the derived sub at each step:
(deftest visible-todos-filters-by-showing
(rf-test/run-test-sync
(rf/dispatch [:todos/add "buy milk"])
(rf/dispatch [:todos/add "write tests"])
(rf/dispatch [:todos/toggle-done 1])
(rf/dispatch [:todos/set-showing :active])
(is (= ["write tests"] (map :title @(rf/subscribe [:todos/visible]))))))
When asserting the same sub at multiple steps, bind it once — subscribe caches by query vector, so this is equivalent but reads better and shows the Reaction tracking state over the test:
(let [tab (rf/subscribe [:rwb/tab])]
(rf/dispatch [:rwb/set-tab "to-be-hired"])
(is (= "to-be-hired" @tab))
(rf/dispatch [:rwb/set-tab "hired"])
(is (= "hired" @tab)))
Per-dispatch stubbing (re-frame 1.4.7+)
dispatch-sync-with overrides effect handlers for one dispatch and its synchronous cascade — handy in REPL sessions and quick tests without registry churn:
(rf/dispatch-sync-with
[:user/save {:id 42}]
{:http-xhrio (fn [request] (swap! captured conj request))})
View tests (if needed)
- Form-1 components are fns returning hiccup data — call and assert on the data.
- Best structure: outer/inner split — impure outer does
subscribe/dispatch, passes plain values to a pure inner component; test the inner. - Full rendering tests belong to React Testing Library-style tooling, out of scope here.