re-frame-test: Testing re-frame Applications
re-frame's unidirectional data flow — events → effects → db → subscriptions → views — makes applications predictable. It also makes them testable. Every piece of behavior in a re-frame app is a pure function or a data-driven side effect description. re-frame-test provides the tooling to test them without a running browser.
What re-frame-test Provides
Testing re-frame apps has two layers:
- Unit testing — test event handlers, subscriptions, and coeffects in isolation (no re-frame runtime needed)
- Integration testing — dispatch real events through the re-frame system and assert on resulting db state and subscriptions
re-frame-test focuses on the second layer. It gives you synchronous test dispatch and time-travel for testing event sequences.
Installation
;; shadow-cljs.edn
{:source-paths ["src" "test"]
:dependencies [[re-frame "1.3.0"]
[day8.re-frame/test "0.1.5"]]
:builds {:test {:target :node-test
:output-to "out/tests.js"
:ns-regexp "-test$"}}}Unit Testing Event Handlers
Event handlers are pure functions: (db event) → new-db. Test them directly without re-frame:
;; src/myapp/events.cljs
(ns myapp.events
(:require [re-frame.core :as rf]))
(rf/reg-event-db
:add-todo
(fn [db [_ text]]
(update db :todos conj {:id (random-uuid)
:text text
:done false})))
(rf/reg-event-db
:toggle-todo
(fn [db [_ id]]
(update db :todos
(fn [todos]
(mapv (fn [todo]
(if (= id (:id todo))
(update todo :done not)
todo))
todos)))))
(rf/reg-event-db
:clear-completed
(fn [db _]
(update db :todos #(filterv (complement :done) %))));; test/myapp/events_test.cljs
(ns myapp.events-test
(:require [cljs.test :refer [deftest is testing]]
[myapp.events])) ;; register handlers as side effect
(def initial-db
{:todos []
:filter :all})
;; Extract handler functions for direct testing
(defn apply-event [db event]
;; Use re-frame's internal handler dispatch (test-only approach)
(let [handler (re-frame.registrar/get-handler :event (first event))]
(handler db event)))
(deftest add-todo-test
(testing "adds a todo to empty list"
(let [new-db (apply-event initial-db [:add-todo "Buy milk"])]
(is (= 1 (count (:todos new-db))))
(is (= "Buy milk" (-> new-db :todos first :text)))
(is (false? (-> new-db :todos first :done)))))
(testing "adds to existing todos"
(let [db1 (apply-event initial-db [:add-todo "First"])
db2 (apply-event db1 [:add-todo "Second"])]
(is (= 2 (count (:todos db2)))))))
(deftest toggle-todo-test
(testing "marks incomplete todo as done"
(let [db1 (apply-event initial-db [:add-todo "Test"])
id (-> db1 :todos first :id)
db2 (apply-event db1 [:toggle-todo id])]
(is (true? (-> db2 :todos first :done)))))
(testing "marks done todo as incomplete"
(let [db1 (apply-event initial-db [:add-todo "Test"])
id (-> db1 :todos first :id)
db2 (apply-event db1 [:toggle-todo id])
db3 (apply-event db2 [:toggle-todo id])]
(is (false? (-> db3 :todos first :done))))))Unit Testing Subscriptions
Subscriptions are also pure functions over the db. Test the subscription handler function directly:
;; src/myapp/subs.cljs
(ns myapp.subs
(:require [re-frame.core :as rf]))
(rf/reg-sub
:todos
(fn [db _]
(:todos db)))
(rf/reg-sub
:visible-todos
:<- [:todos]
:<- [:filter]
(fn [[todos filter-type] _]
(case filter-type
:all todos
:active (filterv (complement :done) todos)
:completed (filterv :done todos))))
(rf/reg-sub
:completed-count
:<- [:todos]
(fn [todos _]
(count (filter :done todos))));; test/myapp/subs_test.cljs
(ns myapp.subs-test
(:require [cljs.test :refer [deftest is testing]]
[re-frame.core :as rf]
[myapp.subs])) ;; register subs
(deftest visible-todos-test
(let [todos [{:id 1 :text "Done task" :done true}
{:id 2 :text "Active task" :done false}
{:id 3 :text "Another done" :done true}]]
(testing "all filter returns all todos"
(let [handler (re-frame.registrar/get-handler :sub :visible-todos)]
;; For chained subs, test with re-frame-test/run-test-sync
;; For simple subs, test the computation function directly
))
;; Better: use re-frame-test for subscription testing
))For chained subscriptions (using :<-), unit testing the computation function in isolation is more reliable than testing through re-frame's subscription machinery. Extract the computation:
;; src/myapp/subs.cljs
(defn visible-todos-computation [[todos filter-type] _]
(case filter-type
:all todos
:active (filterv (complement :done) todos)
:completed (filterv :done todos)))
(rf/reg-sub
:visible-todos
:<- [:todos]
:<- [:filter]
visible-todos-computation);; test/myapp/subs_test.cljs
(ns myapp.subs-test
(:require [cljs.test :refer [deftest is testing]]
[myapp.subs :refer [visible-todos-computation]]))
(def todos
[{:id 1 :text "Done" :done true}
{:id 2 :text "Active" :done false}])
(deftest visible-todos-test
(testing "all returns everything"
(is (= 2 (count (visible-todos-computation [todos :all] nil)))))
(testing "active filters to incomplete"
(is (= 1 (count (visible-todos-computation [todos :active] nil))))
(is (= "Active" (-> (visible-todos-computation [todos :active] nil)
first :text))))
(testing "completed filters to done"
(is (= 1 (count (visible-todos-computation [todos :completed] nil))))
(is (= "Done" (-> (visible-todos-computation [todos :completed] nil)
first :text)))))Integration Testing with re-frame-test
re-frame.test/run-test-sync dispatches events synchronously and blocks until all effects complete. This lets you test full event dispatch chains:
;; test/myapp/integration_test.cljs
(ns myapp.integration-test
(:require [cljs.test :refer [deftest is testing use-fixtures]]
[re-frame.core :as rf]
[day8.re-frame.test :as rf-test]
[myapp.events]
[myapp.subs]
[myapp.db :refer [default-db]]))
;; Reset app state before each test
(use-fixtures :each
{:before (fn [] (rf/dispatch-sync [:initialize-db]))})
(deftest todo-workflow-test
(rf-test/run-test-sync
;; Add a todo
(rf/dispatch [:add-todo "Write tests"])
;; Verify it appears in the subscription
(let [todos @(rf/subscribe [:todos])]
(is (= 1 (count todos)))
(is (= "Write tests" (-> todos first :text))))
;; Toggle it
(let [id (-> @(rf/subscribe [:todos]) first :id)]
(rf/dispatch [:toggle-todo id]))
;; Verify it's done
(is (true? (-> @(rf/subscribe [:todos]) first :done)))
;; Verify counts
(is (= 1 @(rf/subscribe [:completed-count])))
(is (= 0 @(rf/subscribe [:active-count])))
;; Clear completed
(rf/dispatch [:clear-completed])
;; Verify it's gone
(is (= 0 (count @(rf/subscribe [:todos]))))))Testing Async Effects with run-test-async
For effects that are truly async (HTTP calls, timers), use run-test-async:
;; src/myapp/events.cljs
(rf/reg-event-fx
:load-user
(fn [{:keys [db]} [_ user-id]]
{:db (assoc db :loading? true)
:http-xhrio {:method :get
:uri (str "/api/users/" user-id)
:response-format (ajax/json-response-format {:keywords? true})
:on-success [:load-user-success]
:on-failure [:load-user-failure]}}))
(rf/reg-event-db
:load-user-success
(fn [db [_ user]]
(-> db
(assoc :user user)
(dissoc :loading?))));; test/myapp/http_test.cljs
(ns myapp.http-test
(:require [cljs.test :refer [deftest is async]]
[re-frame.core :as rf]
[day8.re-frame.test :as rf-test]
[myapp.events]))
(deftest load-user-test
(rf-test/run-test-async
;; Mock the HTTP effect
(rf-test/wait-for
[:load-user-success :load-user-failure]
(fn []
(let [user @(rf/subscribe [:user])]
(is (some? user))
(is (= 1 (:id user))))))))run-test-async is more complex — for testing HTTP events, it's often easier to mock the effect handler:
(deftest load-user-mocked-test
(rf-test/run-test-sync
;; Override the http-xhrio effect to fire success synchronously
(rf/reg-fx :http-xhrio
(fn [{:keys [on-success]}]
(rf/dispatch (conj on-success {:id 1 :name "Alice"}))))
(rf/dispatch [:load-user 1])
(is (= {:id 1 :name "Alice"} @(rf/subscribe [:user])))
(is (false? @(rf/subscribe [:loading?])))))Testing Effects
Effect handlers (:http-xhrio, :dispatch-later, custom effects) can be tested by registering a mock:
(ns myapp.effects-test
(:require [cljs.test :refer [deftest is use-fixtures]]
[re-frame.core :as rf]
[day8.re-frame.test :as rf-test]))
(def dispatched-effects (atom []))
(use-fixtures :each
{:before (fn []
(reset! dispatched-effects [])
;; Override :navigate effect
(rf/reg-fx :navigate
(fn [route]
(swap! dispatched-effects conj [:navigate route]))))})
(deftest login-redirects-test
(rf-test/run-test-sync
(rf/dispatch [:login-success {:user {:id 1}}])
(is (= [[:navigate "/dashboard"]]
@dispatched-effects))))Testing Interceptors
Interceptors add cross-cutting behavior to events. Test them by creating a minimal context:
;; src/myapp/interceptors.cljs
(def validate-non-empty
(rf/->interceptor
:id :validate-non-empty
:before (fn [context]
(let [text (-> context :coeffects :event second)]
(if (str/blank? text)
(do (js/console.error "Empty text not allowed")
(assoc context :queue [])) ;; abort dispatch
context)))));; test/myapp/interceptors_test.cljs
(ns myapp.interceptors-test
(:require [cljs.test :refer [deftest is testing]]
[re-frame.core :as rf]
[myapp.interceptors :refer [validate-non-empty]]))
(deftest validate-non-empty-test
(testing "passes non-empty text through"
(let [ctx {:coeffects {:event [:add-todo "Valid text"]}
:queue [:some-handler]}
result ((:before validate-non-empty) ctx)]
;; Queue should be unchanged
(is (= [:some-handler] (:queue result)))))
(testing "aborts on blank text"
(let [ctx {:coeffects {:event [:add-todo ""]}
:queue [:some-handler]}
result ((:before validate-non-empty) ctx)]
;; Queue should be cleared
(is (= [] (:queue result))))))Component Testing with re-frame
For testing Reagent components that use re-frame subscriptions:
(ns myapp.todo-list-test
(:require [cljs.test :refer [deftest is use-fixtures]]
[reagent.core :as r]
[reagent.dom :as rdom]
[re-frame.core :as rf]
[day8.re-frame.test :as rf-test]
[myapp.components :refer [todo-list]]))
(def container (atom nil))
(use-fixtures :each
{:before (fn []
(rf/dispatch-sync [:initialize-db])
(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 todo-list-renders-todos-test
(rf-test/run-test-sync
(rf/dispatch [:add-todo "First item"])
(rf/dispatch [:add-todo "Second item"])
(rdom/render [todo-list] @container)
(r/flush)
(let [items (.querySelectorAll @container ".todo-item")]
(is (= 2 (.-length items))))))CI Setup
- name: Run ClojureScript tests
run: |
npx shadow-cljs compile test
node out/tests.jsSummary
Testing re-frame applications at different layers:
- Event handlers — pure functions, test directly with initial db + event → new db
- Subscription computations — extract the function, test with sample data
- Full dispatch chains — use
run-test-syncfromre-frame-test - Async HTTP events — mock the
:http-xhrioeffect handler - Components — render with Reagent, flush, query DOM
The key insight: re-frame's data-driven design makes mocking straightforward. Effects are just data descriptions — override the handler and control what happens. Subscriptions are pure computations — extract them and test with plain data.