re-frame-test: Testing re-frame Applications

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:

  1. Unit testing — test event handlers, subscriptions, and coeffects in isolation (no re-frame runtime needed)
  2. 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.js

Summary

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-sync from re-frame-test
  • Async HTTP events — mock the :http-xhrio effect 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.

Read more