Skip to content

re-frame.test-support

Fixtures and helpers for tests that exercise runtime state. The fixture resets the runtime around each test so every test starts from the same registrations; the helpers assert on app-db, stub effects for a block of dispatches, commit mounted views, wait for async work, and record traces and errors. Its companion re-frame.test-helpers walks the hiccup a view returns; a test that checks both state and views requires both.

Use this namespace when a test drives the runtime: it dispatches events and reads the state they commit. To test a handler's logic alone you need none of it; call the handler as a function, as Test an event handler shows.

(:require [re-frame.test-support :as ts])
(ns my-app.counter-test
  (:require [clojure.test :refer [deftest use-fixtures]]
            [re-frame.core :as rf]
            [re-frame.test-support :as ts]
            [re-frame.substrate.plain-atom :as plain-atom]))

;; In a real suite, require the app namespace that registers this instead.
(rf/reg-event :counter/inc
  (fn [{:keys [db]} _] {:db (update db :n (fnil inc 0))}))

(use-fixtures :each
  (ts/make-reset-runtime-fixture {:adapter plain-atom/adapter}))

(deftest counter-increments
  (rf/dispatch-sync [:counter/inc])
  (ts/assert-path-equals [:n] 1))

plain-atom/adapter is the headless adapter: app-db lives in a plain atom and nothing renders, so the suite runs on the JVM or Node with no React. The fixture installs it and gives each test a fresh :rf/default frame as its ambient frame, which is why the bare dispatch-sync and assert-path-equals need no :frame.

Nothing here is re-exported from re-frame.core, so a production build never loads test machinery by accident. The macros (with-fx-overrides, with-trace-recorder!, with-emit-recorder!) resolve through the same ts alias on the JVM and on CLJS, because the namespace self-requires its macros as re-frame.core does. Tests drive the runtime with the ordinary facade functions (rf/dispatch-sync, rf/app-db-value, …). Test a pipeline run shows these fixtures in use.

Fixture machinery

Each test's registrations are rolled back afterwards, whether it passes or fails, while the registrations made when namespaces loaded (the framework's and your app's) survive. The fixtures do this by snapshotting the registrar before the test and restoring it after. Two kinds of load-time setup are cleared for the length of each test and restored afterwards: app-db schemas, and resource registrations once re-frame.resources.test-support is loaded; see make-reset-runtime-fixture. The trap: frames don't isolate registrations shows the failure this prevents.

make-reset-runtime-fixture

  • Kind: function
  • Signature:
    (make-reset-runtime-fixture)      → fixture-fn
    (make-reset-runtime-fixture opts) → fixture-fn | {:before … :after …}
    
  • Description: Builds a clojure.test / cljs.test :each fixture that resets the per-process runtime around each test. Pass it to use-fixtures :each.
    • Baseline. The fixture captures its baseline when it is built, at the use-fixtures form, which runs when the test namespace loads. Register, or require, everything the suite needs above that form: a registration made below it is outside the baseline, so frames the test makes with rf/make-frame do not see it, and another namespace's fixture can roll it back. Setup that must run later belongs in :init-fn.
    • Before each test, the fixture:
      • reinstates the baseline registrations, so tests do not depend on run order inside a shared test bundle, then snapshots the registrar;
      • drops every frame, clears the trace listeners, cancels pending :dispatch-later timers, and resets epoch history and each loaded artefact's state (flows, machines, routing, resources, http); an artefact that is not on the classpath is skipped;
      • returns epoch-history configuration to its defaults, so a suite that needs another :depth sets it in :init-fn;
      • disposes the adapter, then installs :adapter if given;
      • clears the kinds named in :clear-kinds, registers the :app-ns registrations again, and runs :init-fn.
    • After each test, in a finally so it runs even when the test throws, the fixture restores the registrar to the snapshot and drops every frame again.
    • App-db schemas are cleared at every reset, your app's load-time ones and its :app-ns ones included, and restored afterwards, so a schema registered at namespace load is not checked inside a test body and one co-loaded app's schemas never check another app's frames. Register the schemas a test needs in :init-fn or in the test itself; every frame the test makes resolves them.
    • Resources. Loading re-frame.resources.test-support adds the resources reset to the resources artefact's lifecycle contribution, which the fixture runs. At each reset it clears every resource, mutation and resource-scope registration, together with the resource caches, attempt handles, timers and revalidation listeners. That includes registrations made at namespace load, so for these kinds the baseline does not survive into the test body. A suite that registers resources at namespace load and loads re-frame.resources.test-support therefore needs :app-ns to see them in its test bodies: registrations under the prefix are registered again after the reset, before :init-fn. Without that namespace loaded, resource registrations follow the ordinary rule.
    • Async. :async? true returns a cljs.test map fixture {:before … :after …} on CLJS, which suites with (async done …) tests require. On the JVM the option is ignored and you always get a function fixture: clojure.test calls its fixtures, and a map is callable, so a map fixture would silently skip every test. A .cljc suite therefore writes a plain :async? true, with no reader conditional.
    • :app-ns is for test runs that load every test namespace before any test runs: a CLJS node test bundle, or a JVM runner that requires all its test namespaces first. When two loaded apps register the same id (:rf.route/not-found, or shared event names), a frame made without :images raises :rf.error/ambiguous-registration when it uses that id, in any suite whose baseline was captured after the second app loaded.
      • Give :app-ns your own app's root namespace prefix, covering its whole tree ("my-app.", not "my-app.core"), never a sibling's. Every suite that uses the app declares it, not only the one that loads it first; then no suite needs its siblings' names or depends on load order. A bundle with one app needs this key only for resources it registers at namespace load (see Resources).
      • When the fixture is built, before it captures a baseline, registrations whose :rf.provenance/ns starts with the prefix are removed. They are registered again before each test (after the reset, before :init-fn) and removed again afterwards, even when the test throws.
  • Options (all optional):

    Key Meaning
    :adapter Substrate adapter to install; also ensures the :rf/default frame. When omitted, no adapter is installed and a test that creates a frame raises :rf.error/adapter-disposed (:rf.error/no-adapter-installed if no adapter was installed earlier in the process); calling handlers as functions and reading the registrar still work.
    :app-ns Namespace-prefix string naming this suite's own app, such as "my-app.". Its registrations are kept out of every baseline and registered again before each test. See :app-ns.
    :init-fn Zero-arg fn run after the reset and before the test body, in the same ambient frame scope as the body.
    :clear-kinds Collection of registrar kinds, as listed under rf/clear, such as [:event :sub], cleared after the snapshot and before the body. The snapshot restores them afterwards. An unknown kind clears nothing and is not an error.
    :ambient-frame Frame id bound as the body's ambient scope when an adapter is installed. Default :rf/default; pass nil to opt out, for tests that create their own top-level frames. With nil, a dispatch or subscription that does not name its frame raises :rf.error/no-frame-context instead of landing in :rf/default. The fixture creates only :rf/default; another id is bound as given, so that frame must exist before the body dispatches.
    :async? Boolean, default false. Marks the suite as having async tests; the fixture's shape then depends on the platform. See Async.
    - Example:
    (use-fixtures :each
      (ts/make-reset-runtime-fixture {:adapter plain-atom/adapter}))
    
    ;; A CLJS suite with (async done …) tests gets the map-form fixture.
    (use-fixtures :each
      (ts/make-reset-runtime-fixture {:adapter plain-atom/adapter :async? true}))
    
    ;; A suite whose tests make their own frames: no ambient :rf/default.
    (use-fixtures :each
      (ts/make-reset-runtime-fixture {:adapter plain-atom/adapter :ambient-frame nil}))
    
    (deftest checkout-counts
      (rf/with-new-frame [_ (rf/make-frame {:id :checkout})]   ;; destroyed when the body exits
        (rf/dispatch-sync [:counter/inc])                      ;; targets :checkout
        (ts/assert-path-equals [:n] 1 {:frame :checkout})))
    
    ;; A bundle that also loads other apps.
    (use-fixtures :each
      (ts/make-reset-runtime-fixture
        {:adapter reagent-adapter/adapter
         :app-ns  "my-app."          ; rows under this prefix belong to this suite
         :init-fn init!}))
    

snapshot-registrar

  • Kind: function
  • Signature:
    (snapshot-registrar) → snapshot
    
  • Description: Captures the current registrar state and returns it as a snapshot for restore-registrar!. make-reset-runtime-fixture does this for you; call it directly only for a custom fixture. The snapshot covers the registrar only, not the source store a frame made without :images reads: registrations a test makes stay visible to those frames, and two test namespaces registering the same id make that id raise :rf.error/ambiguous-registration where such a frame uses it. make-reset-runtime-fixture isolates both.
  • Example:
    (let [snap (ts/snapshot-registrar)]
      (try
        ;; ... test body registers extra handlers / subs ...
        (finally
          (ts/restore-registrar! snap))))
    

restore-registrar!

  • Kind: function
  • Signature:
    (restore-registrar! snapshot) → nil
    
  • Description: Restores the registrar to a snapshot taken by snapshot-registrar. Returns nil.
  • Example:
    (ts/restore-registrar! snap)
    

Assertions

assert-path-equals

  • Kind: function
  • Signature:
    (assert-path-equals path expected-val)      → boolean
    (assert-path-equals path expected-val opts) → boolean
    
  • Description: Asserts that (get-in app-db path) equals expected-val in the resolved frame, reporting the result through clojure.test's do-report as is does. Returns true on pass and false otherwise; either result has already been reported to clojure.test.
    • opts: :frame names another frame, as either a frame id or a frame value (what rf/make-frame returns). Without it, the frame is the current frame scope: a with-frame or with-new-frame binding, or else the fixture's ambient frame (:rf/default unless :ambient-frame names another). Outside any scope there is no fallback to :rf/default. An unknown or destroyed frame, and a call outside any scope, fail the assertion whatever expected-val is, nil included; the failure is reported, not raised.
    • It mirrors the :rf.assert/path-equals event Story uses, under the same name.
    • To fire several events before asserting, call rf/dispatch-sync once per event. Each call drains fully before the next, so the state between calls reflects every committed effect.
    • For a whole-db assertion, compare directly: (is (= expected-db (rf/app-db-value frame-id))).
    • See Checking one path.
  • Example:
    (doseq [ev [[:counter/inc] [:counter/inc] [:counter/dec]]]
      (rf/dispatch-sync ev))
    (ts/assert-path-equals [:n] 1)
    
    ;; Against another frame:
    (ts/assert-path-equals [:cart :count] 2 {:frame :checkout})
    

Stubbing effects for a block of dispatches

with-fx-overrides

  • Kind: macro
  • Signature:
    (with-fx-overrides overrides body+) → value of body
    
  • Description: overrides is a map {fx-id override}. Replaces effect handlers for every dispatch and dispatch-sync inside body, usually to stub an effect in a test. Each such call merges the map under its own :fx-overrides, so a per-call key wins. The overrides apply for the dynamic extent of body and compose with with-frame.
    • Precedence, highest first: per call ((rf/dispatch event {:fx-overrides {...}})), then scoped (with-fx-overrides), then per frame ((rf/make-frame {:id :todo :fx-overrides {...}})).
    • An override value is the id of another registered fx, which runs in place of the original. An override may also be a function, called like a reg-fx handler with ctx and args, for test wiring; an epoch recorded under a function override cannot be replayed with replay-epoch!.
    • nil or false means no override, so {:app/save (when stub? :app/save-stub)} is safe. An unregistered id or any other value emits :rf.error/override-fallthrough, and the original effect runs.
    • Events queued with :dispatch or :dispatch-later from inside the scope keep the overrides.
    • See Asserting on what would dispatch.
  • Example:
    ;; Capture the login request instead of sending it, for the test body —
    ;; every dispatch inside inherits the override; it unwinds when the body exits.
    (let [sent (atom [])]
      (ts/with-fx-overrides {:rf.http/managed (fn [_ctx args] (swap! sent conj (:request args)))}
        (rf/dispatch-sync [:auth.login/submit {:email "x@y.z" :password "wrong"}]))
      @sent)
    

Committing mounted views

flush-views!

  • Kind: function (CLJS and JVM)
  • Signature:
    (flush-views!)   → nil
    (flush-views! f) → nil
    
  • Description: Runs the thunk f, then commits the views it re-rendered, synchronously, so the next line reads the DOM a user would see. The 0-arity form commits renders already pending. Use it when a test mounts views into a real DOM; most view tests need no DOM, and call the view and walk its hiccup with re-frame.test-helpers instead.
    • It names no adapter. The commit is the installed adapter's: Reagent drains its render queue, UIx runs React's flushSync. On CLJS it runs inside React's act(), so the effects that commit scheduled have run too. React's production build has no act(), and there it commits without one.
    • It settles only what ran synchronously: a mount, or a dispatch-sync inside f. A queued dispatch, an HTTP reply or a timer has not happened when it returns; wait for those with poll-until.
    • Where nothing renders live (the plain-atom and SSR adapters, and every JVM run) it runs f and returns nil. With no adapter installed it raises :rf.error/no-adapter-installed.
    • React's act() expects the test to set globalThis.IS_REACT_ACT_ENVIRONMENT to true while it drives React through flush-views!, and to set it back while it waits on React's own schedule. Test a view §4 shows the pattern.
    • A Fresco mounted test settles with re-frame.fresco.test.mounted/settle! instead, because act() diverts work away from the browser's scheduler; see Fresco testing.
    • It runs a render, so do not call it from inside an event handler.
  • Example:
    (ts/flush-views! #(reagent-adapter/render! root [counter] el))  ;; mount, committed on return
    (ts/flush-views! #(rf/dispatch-sync [:counter/inc]))            ;; dispatch, then commit the re-render
    (is (= "1" (.-textContent el)))
    

Waiting for async work

poll-until

  • Kind: function
  • Signature:
    (poll-until pred)      → truthy value (JVM) | js/Promise (CLJS)
    (poll-until pred opts) → truthy value (JVM) | js/Promise (CLJS)
    
  • Description: Calls pred repeatedly until it returns a truthy value or a deadline passes. Use it in place of a fixed sleep when a test waits on a queued dispatch, an HTTP reply or a timer.
    • JVM: synchronous. Returns the truthy value, or throws ex-info on timeout.
    • CLJS: returns a js/Promise that resolves with the truthy value or rejects on timeout. A pred that returns a js/Promise is awaited, and its resolved value is tested. Call it from an (async done …) test, whose suite fixture needs :async? true.
    • A pred that throws counts as falsy, and polling continues, so it can read state that is still mid-change.
    • A rejected Promise also counts as falsy. The deadline is checked after a falsy probe settles: it does not interrupt a blocking predicate or a Promise that never settles, and a truthy result wins even if it arrives after the deadline. Prefer a quick state read; give any async operation inside the predicate its own timeout.
    • The timeout error carries :rf.error/id :rf.error/poll-until-timeout, plus :elapsed-ms and :label in its data.
    • opts: :timeout-ms (default 2000), :interval-ms (default 5), :label (a string or keyword, shown in the timeout message).
    • On CLJS, handle the rejection before the step that calls done, and call done once, last. A .catch placed after done reports a failure from a later namespace against this test and calls done a second time.
    • See Observing the :loading state, which uses it with a delayed HTTP reply and says why not to poll across a debounce window.
  • Example:
    ;; JVM: synchronous; returns the truthy value (throws on timeout).
    (rf/dispatch [:counter/inc])                    ;; queued, not yet drained
    (rf/dispatch [:counter/inc])
    (is (ts/poll-until #(= 2 (:n (rf/app-db-value :rf/default)))
                       {:label "counter reached 2"}))
    
    ;; CLJS: returns a js/Promise, composed under cljs.test/async.
    (deftest counter-reaches-2
      (async done
        (rf/dispatch [:counter/inc])
        (rf/dispatch [:counter/inc])
        (-> (ts/poll-until #(= 2 (:n (rf/app-db-value :rf/default)))
                           {:label "counter reached 2"})
            (.catch (fn [e] (is false (.-message e)) nil))   ;; report a timeout
            (.then  (fn [_] (done))))))                       ;; finish once, last
    

Recording traces and errors

Both macros bracket a body with a fresh listener: it is registered before the body runs and unregistered in a finally, even if the body throws. The records land in an atom bound to the symbol you name, and the macro returns the value of the body's last form.

The listener lasts only for the synchronous body. Returning a Promise does not keep it registered until that Promise settles. Use dispatch-sync for a synchronous recording; on the JVM a blocking poll-until can keep the body open. For queued CLJS work, collect traces with a trace listener and unregister it after the async assertion finishes.

with-trace-recorder!

  • Kind: macro
  • Signature:
    (with-trace-recorder! [recs-sym] body+)      → value of the last body form
    (with-trace-recorder! [recs-sym opts] body+) → value of the last body form
    
  • Description: Records the trace events emitted while body runs into an atom bound to recs-sym.
    • opts is an optional map literal, read at macroexpansion. Pass the map itself: a symbol naming a map is not read, and every default applies. A binding vector of any other length, or a :shape other than :flat or :by-op, throws at macroexpansion. The keys:
      • :pred: a 1-arg (fn [ev] truthy?) filter. Default: accept every event.
      • :shape: :flat (default; the atom holds a vector of events) or :by-op (a map keyed by (:operation ev)).
      • :key: the listener key. Default: a fresh keyword each time the bracket runs, so brackets never collide, even nested ones.
  • Example:
    ;; Flat shape (default), every event.
    (ts/with-trace-recorder! [traces]
      (rf/dispatch-sync [:counter/inc])
      (is (= 1 (count (filter #(= :rf.event/run-start (:operation %))
                              @traces)))))
    
    ;; :by-op shape with a :pred filter.
    (ts/with-trace-recorder! [observed
                              {:pred  #(contains? #{:rf.event/run-start
                                                    :rf.event/run-end}
                                                  (:operation %))
                               :shape :by-op}]
      (rf/dispatch-sync [:counter/inc])
      (rf/dispatch-sync [:counter/inc])
      (is (= 2 (count (:rf.event/run-start @observed))))
      (is (= 2 (count (:rf.event/run-end @observed)))))
    

with-emit-recorder!

  • Kind: macro
  • Signature:
    (with-emit-recorder! [recs-sym] body+)      → value of the last body form
    (with-emit-recorder! [recs-sym opts] body+) → value of the last body form
    
  • Description: Records what the always-on error or event stream emits while body runs into an atom bound to recs-sym. It is the always-on counterpart of with-trace-recorder!: these streams also run in production builds.
    • opts is an optional map literal, read at macroexpansion. Pass the map itself: a symbol naming a map is not read, and every default applies. A binding vector of any other length throws at macroexpansion. The keys:
      • :stream: :errors (default; one record per error the always-on error stream carries) or :events (one record per processed event). Any other value throws at macroexpansion.
      • :pred: a 1-arg (fn [record] truthy?) filter. Default: accept every record.
      • :key: the listener key. Default: a fresh keyword each time the bracket runs, so brackets never collide, even nested ones.
    • An :errors record is {:rf.error/id … :event … :event-id … :frame … :time … :exception … :elapsed-ms … :source-coord …}, :rf.error/id being the :rf.error/* id. Only runtime errors carried on the always-on stream produce one, such as a handler, effect or subscription failure, or a dispatch to an unregistered event (:rf.error/no-such-handler). An error reported only on the trace stream produces none, such as a registration-time error or a refusal from rf.epoch/restore-epoch! or rf.epoch/replace-frame-state!; record those with with-trace-recorder!. An error thrown to the caller, such as :rf.error/poll-until-timeout, produces none either, so catch that one instead.
    • An :events record is {:event … :event-id … :frame … :time … :outcome … :elapsed-ms …}. :outcome is :ok, :error (the handler or an interceptor threw), :rolled-back (a schema rejected the new app-db), :flow-error (a flow's output threw) or :rejected (a :boundary? true handler's :schema refused the payload). An event whose handler sets :rf.trace/no-emit? true produces no record.
    • In both, :event has already passed the off-box elision: a declared-sensitive argument reads :rf/redacted, and an oversized one a :rf.size/large-elided marker. The rest of the record is unprojected, which is what a test wants: :exception is the raw throwable.
    • These streams have no public listener function, and rf/register-listener! has no :events or :errors stream: an application reads production records, projected, through a frame's :observability sink or the (rf/configure! {:observability …}) process default.
  • Example:
    ;; Default stream (:errors): capture what one dispatch fails with.
    (ts/with-emit-recorder! [errs]
      (rf/dispatch-sync [:boom])
      (is (= [:rf.error/handler-exception] (mapv :rf.error/id @errs))))
    
    ;; The event stream, filtered to one frame (:app/main made with rf/make-frame).
    (ts/with-emit-recorder! [seen {:stream :events
                                   :pred   #(= :app/main (:frame %))}]
      (rf/dispatch-sync [:tick] {:frame :app/main})
      (rf/dispatch-sync [:tick])                     ;; lands in :rf/default, filtered out
      (is (= 1 (count @seen))))
    

See also

  • re-frame.core: the production functions tests drive (make-frame, with-frame, dispatch-sync, app-db-value, compute-sub) and registrar introspection (registrations, handler-meta).
  • Managed HTTP reference: HTTP stubs for tests that exercise managed requests.
  • Test an event handler: testing a handler as a pure function.