Skip to content

Test a pipeline run

Some behaviour only shows up across several events. A handler writes state and fires an HTTP request; the reply arrives as another event, whose handler writes more state and may dispatch more. Each dispatch is one pipeline run, one pass through the event pipeline, and follow-up dispatches are their own runs inside the same drain.

This page tests that chain end to end: an event goes in, the queue drains, and you assert on the committed app-db. The handlers run unmodified and return the same effect data as in production; the test only changes what answers those effects.

Coming from MSW?

Mock Service Worker intercepts at the network boundary and answers with mock responses. A re-frame2 stub transport sits at the same boundary, inside the runtime: the real :rf.http/managed builds, classifies and decodes every request, and only the wire is fake, so there is no service worker to install. And because the request is an effect, data the handler returns, a test can also inspect it without sending it.

The shape of every pipeline-run test

Create a fresh frame, call dispatch-sync, and assert against the frame's app-db. The handlers are the todo app's, from Test an event handler:

(deftest toggle-a-new-todo
  (rf/with-new-frame [f (rf/make-frame
                          {:fx-overrides   {:todo.storage/save (fn [_ _] nil)}
                           :initial-events [[:todo/add "Buy milk"]]})]
    (rf/dispatch-sync [:todo/toggle 1])
    (is (true? (get-in (rf/app-db-value f) [:todos 1 :done?])))))

with-new-frame gives the test its own frame and destroys it on exit, like with-open. dispatch-sync returns only when the drain has settled: the event runs, every follow-up :dispatch runs, and, as shown below, every stubbed HTTP reply runs too. This is run-to-completion, so the next line reads committed state. There is no act(), no fake timer to advance and nothing to await.

From re-frame v1

dispatch-sync drains follow-up dispatches too, so the v1 test library's wait-for and run-test-sync have no counterpart. Call dispatch-sync directly.

The code under test

Effects loaded the todo list from a server with :todo/fetch and its two reply events. Here the same three events also record when the request went out and a sync status, which gives the tests more to check. The file is .cljc, so the same code runs in the app and under a JVM test runner.

;; src/my_app/sync.cljc
(ns my-app.sync
  (:require [re-frame.core :as rf]
            [re-frame.http.managed]))   ;; registers :rf.http/managed

(rf/reg-event :todo/fetch
  {:rf.cofx/requires [:rf/time-ms]}
  (fn [{:keys [db rf/time-ms]} _]
    {:db (assoc db :sync-status :loading :requested-at time-ms)
     :fx [[:rf.http/managed
           {:request    {:method :get :url "/api/todos"}
            :on-success [:todo/fetched]
            :on-failure [:todo/fetch-failed]}]]}))

(rf/reg-event :todo/fetched
  (fn [{:keys [db]} [_ {:keys [value]}]]
    {:db (assoc db :sync-status :loaded
                   :todos (into {} (map (juxt :id identity)) value))}))

(rf/reg-event :todo/fetch-failed
  (fn [{:keys [db]} [_ {:keys [error]}]]
    {:db (assoc db :sync-status :error :sync-error (:kind error))}))

The test can substitute values at two points without touching the handlers. The clock is a declared coeffect, so the test can supply an exact value. The request is an effect in the returned map, so the test can answer it without a network.

Managed HTTP appends a reply map to the :on-success or :on-failure event: {:status :ok :value <decoded-body> …} or {:status :error :error <failure-map> …}. That is why :todo/fetched destructures :value (here the decoded vector of todos) and :todo/fetch-failed destructures :error, whose :kind is one of seven :rf.http/* categories. Managed HTTP lists the full reply map.

The test

;; test/my_app/sync_test.clj
(ns my-app.sync-test
  (:require [clojure.test :refer [deftest is use-fixtures]]
            [re-frame.core :as rf]
            [re-frame.http.stub-transport :as stub-transport]   ;; test-only; never in production requires
            [re-frame.substrate.plain-atom :as plain-atom]
            [re-frame.test-support :as ts]
            [my-app.todos]    ;; :todo/add, :todo/toggle, :todo/set-showing, …
            [my-app.sync]))

;; Installs the headless adapter every frame needs and resets the runtime per test.
(use-fixtures :each (ts/make-reset-runtime-fixture {:adapter plain-atom/adapter}))

(deftest fetch-happy-path
  (rf/with-new-frame [f (rf/make-frame {})]
    (stub-transport/with-transport
      (stub-transport/route-transport
        {[:get "/api/todos"] {:body [{:id 1 :title "Buy milk" :done? false}]}})
      (fn []
        (rf/dispatch-sync [:todo/fetch] {:rf.cofx {:rf/time-ms 1781078400000}})
        (let [db (rf/app-db-value f)]
          (is (= :loaded (:sync-status db)))
          (is (= 1781078400000 (:requested-at db)))
          (is (= "Buy milk" (get-in db [:todos 1 :title]))))))))

(deftest fetch-server-error
  (rf/with-new-frame [f (rf/make-frame {})]
    (stub-transport/with-transport
      (stub-transport/route-transport
        {[:get "/api/todos"] {:status 503}})
      (fn []
        (rf/dispatch-sync [:todo/fetch])
        (is (= :error            (:sync-status (rf/app-db-value f))))
        (is (= :rf.http/http-5xx (:sync-error  (rf/app-db-value f))))))))

Run it with your project's JVM test runner (clojure -M:test). Both tests cover the full chain: request out, reply in, reply handler updates state. The reset fixture is the one Testing sets up.

Supply the facts: {:rf.cofx {...}}

The router stamps :rf/time-ms on every dispatch, which is why the second test runs without mentioning it. Left alone it is the live clock, and an assertion on :requested-at would be flaky, so the first test pins it. Facts supplied under :rf.cofx win, and the runtime fills in only what is missing.

A handler receives exactly the facts its :rf.cofx/requires names, so that vector is the test's checklist; read it with (rf/handler-meta {:kind :event :id :todo/fetch}).

Answer the HTTP: a stub transport

with-transport (in re-frame.http.stub-transport) takes a transport and a zero-argument function. While that function runs, every :rf.http/managed request goes out through that transport instead of the network. route-transport builds one from a route map of [method url] → response: {:body value} answers 200 with value as a JSON body, {:status 503} answers with that status, and {:failure {:kind :rf.http/transport}} (or :rf.http/timeout) fails the request before any response arrives. Only the wire is fake. The request still runs through the :before and :after interceptors, status classification, :decode, :accept and retry, so the 503 above arrives as a :rf.http/http-5xx failure, the reply is a live one in every respect, and it lands inside the same dispatch-sync drain.

The body travels as JSON, so it decodes as a server's would: under the default :decode :auto, or :json, object keys arrive as keywords and keyword values arrive as strings, so {:body {:status :done}} reaches the handler as {:status "done"}. A test that wants a reply no server could send dispatches the reply event itself, (rf/dispatch-sync [:todo/fetched {:status :ok :value …}]).

One map can hold several routes. A request is matched on its method and on the URL it goes out on:

(stub-transport/with-transport
  (stub-transport/route-transport
    {[:get    "/api/todos"]   {:body []}
     [:post   "/api/todos"]   {:body {:todo {:id 3 :title "Call mum"}}}
     [:delete "/api/todos/2"] {:status 403}})
  (fn []
    ;; ... dispatch the events whose handlers fire those three requests ...
    ))

That URL is the one the :before interceptors leave, with any :params merged in as a query string, so key a request carrying :params {:page 2} as "/api/todos?page=2". A route's value can also be (fn [wire-request] response), which sees the encoded request as it would go out.

A request that matches no route fails as :rf.http/transport, with a message naming its method and URL, through the normal :on-failure path. The miss shows up in your failure handler's state, where the next assertion catches it, so the map must name every request the path under test fires.

Redirect any effect: :fx-overrides

A stub transport answers a request; :fx-overrides replaces the effect that would send it. A per-dispatch :fx-overrides map redirects any effect id for that one dispatch, to a function or to another registered effect:

(deftest fetch-sends-the-right-request
  (rf/with-new-frame [f (rf/make-frame {})]
    (let [sent (atom nil)]
      (rf/dispatch-sync [:todo/fetch]
                        {:fx-overrides {:rf.http/managed
                                        (fn [_frame-ctx args] (reset! sent args))}})
      (is (= :get         (get-in @sent [:request :method])))
      (is (= "/api/todos" (get-in @sent [:request :url]))))))

An override value is either another registered fx id (a keyword) or a function (fn [frame-ctx args] …). The function receives the exact args map the handler built, so you assert on the request without performing it, and its return value is ignored. The same option captures your own effects, such as :todo.storage/save. An overridden request sends nothing, so nothing replies; to test the reply handler as well, answer the request through a stub transport, or dispatch the reply event directly:

(rf/dispatch-sync [:todo/fetched {:status :ok :value [{:id 1 :title "Buy milk" :done? false}]}])

The :test preset

Most test frames want HTTP kept off the network and generated facts strict. {:preset :test} on rf/make-frame does both:

;; Never reaches the network; an unsupplied generated cofx raises an error.
(rf/with-new-frame [f (rf/make-frame {:preset :test})]
  (stub-transport/with-transport
    (stub-transport/route-transport {[:get "/api/todos"] {:body []}})
    #(rf/dispatch-sync [:todo/fetch]))
  (is (= :loaded (:sync-status (rf/app-db-value f)))))

The preset expands to :rf.http/stub-only? true, :rf.cofx/mint-policy :strict and :drain-depth 1000, the framework default. Under :rf.http/stub-only? a request that no stub transport answers fails as :rf.http/transport, with a message naming with-transport and install-transport!, so a forgotten stub shows up as a failure instead of a live call. Your own keys win over the expansion.

Asserting on what would dispatch

Sometimes the thing under test is which event a handler dispatches next. :dispatch is itself an effect that can be overridden, so a per-call :fx-overrides {:dispatch …} captures the event vector instead of queueing it:

;; src/my_app/todos.cljc
(rf/reg-event :todo/clear-done
  (fn [{:keys [db]} _]
    {:db (update db :todos #(into {} (remove (comp :done? val)) %))
     :fx [[:dispatch [:todo/set-showing :all]]]}))

;; test/my_app/sync_test.clj
(deftest clear-done-resets-the-filter
  (rf/with-new-frame [f (rf/make-frame {})]
    (let [dispatched (atom [])]
      (rf/dispatch-sync [:todo/clear-done]
                        {:fx-overrides {:dispatch (fn [_ ev] (swap! dispatched conj ev))}})
      (is (= [[:todo/set-showing :all]] @dispatched)))))

A per-call override applies to the run it starts, and to that run's :dispatch and :dispatch-later children. It does not reach an HTTP reply, which is a new dispatch. To cover the reply too, wrap the body in ts/with-fx-overrides: every dispatch made while the body runs carries the override, including stubbed replies. Precedence is per-call opt, then with-fx-overrides, then the frame's :fx-overrides.

Suppose :todo/fetched also returns :fx [[:dispatch [:todo/set-showing :all]]]. This test checks that dispatch without running it:

(deftest fetched-resets-the-filter
  (rf/with-new-frame [f (rf/make-frame {})]
    (let [dispatched (atom [])]
      (ts/with-fx-overrides {:dispatch (fn [_ ev] (swap! dispatched conj ev))}
        (stub-transport/with-transport
          (stub-transport/route-transport {[:get "/api/todos"] {:body []}})
          (fn [] (rf/dispatch-sync [:todo/fetch]))))
      (is (= :loaded (:sync-status (rf/app-db-value f))))
      (is (= [[:todo/set-showing :all]] @dispatched)))))

Gotcha: scope a :dispatch override to a call, never to a frame

In a frame's config, a :dispatch override applies to every dispatch routed to that frame, including framework traffic such as machine messages and HTTP reply handling. Scope it to one dispatch, or to a with-fx-overrides body.

Replay a bug as a regression test

A user reports: "I ticked a todo, unticked it, cleared done todos, and it disappeared anyway." That report is a list of events. app-db is the result of applying each event in turn to the initial state, so replaying the same list rebuilds the same state, bug included.

If Xray was open when the bug happened, copy the exact events (and each one's :rf.cofx facts) from its recorded epoch history. The test replays the sequence and asserts the correct result:

(deftest issue-217-clear-done-removes-unticked-todo
  (rf/with-new-frame [f (rf/make-frame
                          {:initial-events [[:rf/set-db {:todos {1 {:id 1 :title "Buy milk" :done? false}}
                                                         :showing :all}]]})]
    (doseq [ev [[:todo/toggle 1]
                [:todo/toggle 1]
                [:todo/clear-done]]]
      (rf/dispatch-sync ev))
    (is (= "Buy milk" (get-in (rf/app-db-value f) [:todos 1 :title])))))

A doseq over dispatch-sync runs the events in order, each draining before the next. The sequence belongs in the test body because it is what is under test; the starting state is setup, so it stays in the frame's construction. When a replayed event's handler declares facts, give that event its own dispatch-sync carrying the recorded :rf.cofx. The test fails until the handler is fixed and then stays as a regression test.

When the bug is "the state was briefly wrong between two events", read state after each event:

(let [seen (atom [])]
  (doseq [ev [[:todo/toggle 1] [:todo/toggle 1] [:todo/clear-done]]]
    (rf/dispatch-sync ev)
    (swap! seen conj [(first ev) (get-in (rf/app-db-value f) [:todos 1 :done?])]))
  @seen)
;; => [[:todo/toggle true] [:todo/toggle false] [:todo/clear-done false]]

For one path, ts/assert-path-equals gives a clojure.test failure message naming the frame and path, and inside with-new-frame it reads that frame without a :frame option (see Test an event handler).

Asserting on a derived value

Sometimes the value worth checking is derived, such as a count or a filtered list. compute-sub computes a subscription, here :todo/remaining-count from Subscriptions, against the frame's app-db, so the assertion covers the sub's logic too:

(deftest remaining-after-clear
  (rf/with-new-frame [f (rf/make-frame
                          {:initial-events [[:rf/set-db {:todos {1 {:id 1 :title "Buy milk"     :done? true}
                                                                 2 {:id 2 :title "Walk the dog" :done? false}}}]]})]
    (rf/dispatch-sync [:todo/clear-done])
    (is (= 1 (rf/compute-sub [:todo/remaining-count] (rf/app-db-value f))))))

(rf/compute-sub query-v db) runs the subscription against an app-db value with no reactive cache and no adapter, computing any input subs first. It returns nil if the sub body throws (:rf.error/sub-exception). An input naming an unregistered sub computes to nil without an error record, and the body runs on that nil. To read what a running frame's cache holds instead, use subscribe-once; Test a subscription covers compute-sub.

What the dispatch opts can change

Every opt on this page changes something around the handler, never the handler function itself:

Opt What it changes
:frame which frame the event runs in
:rf.cofx the facts the handler receives
:rf.cofx/mint-policy whether an unsupplied generated fact is produced (:live) or raises an error (:strict)
:fx-overrides what performs the effects the handler returns
:interceptor-overrides which interceptors are removed or replaced for this dispatch

A key the runtime does not know is ignored.

Because the handler itself can't be replaced, a passing test says that the production handler, given those inputs, returns those effects. Replaying recorded events with their recorded inputs therefore computes the same state production computed.

Troubleshooting

Symptom Cause Fix
A stubbed test fails with a :rf.http/transport failure whose message reads no stub route for … A request the path fires has no route; the key is the URL the request goes out on, :params included as a query string Add the route, keyed by the method and URL the message names
Under {:preset :test}, a request fails as :rf.http/transport with a message naming with-transport The preset keeps HTTP off the network, and no stub transport answered Wrap the dispatch in stub-transport/with-transport
The real effect runs despite a keyword override; :rf.error/override-fallthrough in the trace The override names an unregistered effect Require the namespace that registers it, or override with a function
A test runs on the live clock despite :rf.cofx; :rf.warning/unknown-dispatch-opt in the trace A misspelt opt such as :rf/cofx is ignored Spell it :rf.cofx
:rf.error/missing-required-cofx or :rf.error/unregistered-cofx A declared fact the runtime can't supply, or nothing registers it Supply it under :rf.cofx, or register the cofx
dispatch-sync stops with partly advanced state; :rf.error/drain-depth-exceeded A handler re-dispatches itself, or a stubbed reply re-fires its request Break the cycle; see A runaway family

Advanced

Observing the :loading state before the reply lands

The tests above assert the settled state, so they never see :sync-status :loading. To test the in-flight state (a spinner, a disabled button), hold the reply. pending-transport returns a transport that answers nothing until the test does, and an atom of the requests it holds, in issue order; each one's :respond! answers it with a response map, and :fail! fails it. The reply then arrives as a new dispatch, after dispatch-sync has returned:

(deftest fetch-shows-loading-then-loaded
  (rf/with-new-frame [f (rf/make-frame {})]
    (let [{:keys [transport exchanges]} (stub-transport/pending-transport)]
      (stub-transport/with-transport transport
        #(rf/dispatch-sync [:todo/fetch]))
      ;; The request fired but the reply hasn't landed yet.
      (is (= :loading (:sync-status (rf/app-db-value f))))
      ;; Answer it, wait for the reply, then assert the final state.
      ((:respond! (first @exchanges)) {:body []})
      (ts/poll-until #(= :loaded (:sync-status (rf/app-db-value f))))
      (is (= :loaded (:sync-status (rf/app-db-value f)))))))

The held request still goes out through the transport the scope gave it, so answering it after with-transport returns is fine.

ts/poll-until retries a predicate after a falsy result until it succeeds or its timeout is exceeded (defaults :timeout-ms 2000, :interval-ms 5). Keep the predicate quick: the timeout is checked between failed probes and does not interrupt a blocked predicate or a CLJS Promise that never settles. On the JVM it returns the truthy value or throws an ex-info carrying :rf.error/poll-until-timeout. On CLJS it returns a js/Promise to compose under cljs.test/async. The optional :label goes into the timeout message. Use it whenever work settles after dispatch-sync returns: a held or delayed reply, a machine :after transition, a :dispatch-later.

Don't use poll-until to wait out a timer window such as a debounce. There the duration is what you are testing, so use a Thread/sleep and say so in a comment.

One reply handler: :reply-to

Instead of :on-success and :on-failure, a request can name one event, :reply-to, that receives the reply for both outcomes; the handler branches on the reply's :status:

;; src/my_app/sync.cljc
(rf/reg-event :todo/fetch-one
  (fn [_ [_ id]]
    {:fx [[:rf.http/managed {:request  {:method :get :url (str "/api/todos/" id)}
                             :reply-to [:todo/fetched-one id]}]]}))

(rf/reg-event :todo/fetched-one
  (fn [{:keys [db]} [_ id {:keys [status value error]}]]
    {:db (assoc-in db [:todos id]
                   (if (= :ok status) value {:id id :error (:kind error)}))}))

;; test/my_app/sync_test.clj
(deftest fetch-single-reply-target
  (rf/with-new-frame [f (rf/make-frame {})]
    (stub-transport/with-transport
      (stub-transport/route-transport
        {[:get "/api/todos/2"] {:body {:id 2 :title "Walk the dog" :done? false}}})
      (fn []
        (rf/dispatch-sync [:todo/fetch-one 2])
        (is (= "Walk the dog" (get-in (rf/app-db-value f) [:todos 2 :title])))))))

A request must address its reply: one with no :reply-to, :on-success or :on-failure raises :rf.error/http-no-reply-target, and one split key without the other raises :rf.error/http-bad-reply-target (:reason :unpaired-split). An explicit :on-failure nil drops failures on purpose (a telemetry beacon, say), quietly: the nil is the decision.

Removing noisy interceptors: :interceptor-overrides

A logging or analytics interceptor that runs on every event can flood the test output. Remove it for the test frame with :interceptor-overrides, keyed by the interceptor's registered reference:

(rf/make-frame
  {:id                    :test/quiet
   :interceptor-overrides {:my-app/request-logger nil}})   ;; nil removes it

A parameterized reference such as [:rf.interceptor/path [:todos]] is matched by the whole vector. The value is nil (remove) or another registered reference (replace). The same key in the dispatch-sync opts wins over the frame's.

A runaway family halts at :drain-depth

If a handler re-dispatches itself, or a stubbed reply re-fires the request that caused it, the event's family stops when it reaches :drain-depth with events still queued, and the runtime emits :rf.error/drain-depth-exceeded (tags :depth, :queue-size, :last-event-id, :tail-event-ids). A family is the event you dispatched plus every event it dispatches, transitively, and the count restarts with each event from outside. Events that already ran keep their committed :db, the family's remaining queued events are discarded, and nothing is rolled back, so a test that hits the cap reads partly advanced state. The drain then carries on with any event queued from outside. If a dispatch-sync seems to loop, check your error listener for that error.

with-new-frame or with-frame

with-new-frame [f expr] takes a vector: it evaluates expr, binds the result, runs the body, and destroys the frame on exit. with-frame :some-id takes a keyword: it makes an existing frame current and neither creates nor destroys it. The wrong shape fails at compile time with :rf.error/with-new-frame-keyword-form or :rf.error/with-frame-vector-form. Use with-new-frame per test, and with-frame for a frame shared across several deftests.

Frames, dispatch opts and drain behaviour are covered in Frames and Run to completion, the reply map and stub transports in Managed HTTP, and the test helpers in the test-support API reference.