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.
(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:
- Description: Builds a
clojure.test/cljs.test:eachfixture that resets the per-process runtime around each test. Pass it touse-fixtures :each.- Baseline. The fixture captures its baseline when it is built, at the
use-fixturesform, 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 withrf/make-framedo 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-latertimers, 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
:depthsets it in:init-fn; - disposes the adapter, then installs
:adapterif given; - clears the kinds named in
:clear-kinds, registers the:app-nsregistrations again, and runs:init-fn.
- After each test, in a
finallyso 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-nsones 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-fnor in the test itself; every frame the test makes resolves them. - Resources. Loading
re-frame.resources.test-supportadds 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 loadsre-frame.resources.test-supporttherefore needs:app-nsto 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? truereturns acljs.testmap 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.testcalls its fixtures, and a map is callable, so a map fixture would silently skip every test. A.cljcsuite therefore writes a plain:async? true, with no reader conditional. :app-nsis 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:imagesraises:rf.error/ambiguous-registrationwhen it uses that id, in any suite whose baseline was captured after the second app loaded.- Give
:app-nsyour 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/nsstarts 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.
- Give
- Baseline. The fixture captures its baseline when it is built, at the
-
Options (all optional):
Key Meaning :adapterSubstrate adapter to install; also ensures the :rf/defaultframe. When omitted, no adapter is installed and a test that creates a frame raises:rf.error/adapter-disposed(:rf.error/no-adapter-installedif no adapter was installed earlier in the process); calling handlers as functions and reading the registrar still work.:app-nsNamespace-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-fnZero-arg fn run after the reset and before the test body, in the same ambient frame scope as the body. :clear-kindsCollection 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-frameFrame id bound as the body's ambient scope when an adapter is installed. Default :rf/default; passnilto opt out, for tests that create their own top-level frames. Withnil, a dispatch or subscription that does not name its frame raises:rf.error/no-frame-contextinstead 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:
- Description: Captures the current registrar state and returns it as a snapshot for
restore-registrar!.make-reset-runtime-fixturedoes 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:imagesreads: registrations a test makes stay visible to those frames, and two test namespaces registering the same id make that id raise:rf.error/ambiguous-registrationwhere such a frame uses it.make-reset-runtime-fixtureisolates both. - Example:
restore-registrar!¶
- Kind: function
- Signature:
- Description: Restores the registrar to a
snapshottaken bysnapshot-registrar. Returnsnil. - Example:
Assertions¶
assert-path-equals¶
- Kind: function
- Signature:
- Description: Asserts that
(get-in app-db path)equalsexpected-valin the resolved frame, reporting the result throughclojure.test'sdo-reportasisdoes. Returnstrueon pass andfalseotherwise; either result has already been reported toclojure.test.opts::framenames another frame, as either a frame id or a frame value (whatrf/make-framereturns). Without it, the frame is the current frame scope: awith-frameorwith-new-framebinding, or else the fixture's ambient frame (:rf/defaultunless:ambient-framenames 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 whateverexpected-valis,nilincluded; the failure is reported, not raised.- It mirrors the
:rf.assert/path-equalsevent Story uses, under the same name. - To fire several events before asserting, call
rf/dispatch-synconce 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:
Stubbing effects for a block of dispatches¶
with-fx-overrides¶
- Kind: macro
- Signature:
- Description:
overridesis a map{fx-id override}. Replaces effect handlers for everydispatchanddispatch-syncinsidebody, 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 ofbodyand compose withwith-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-fxhandler withctxandargs, for test wiring; an epoch recorded under a function override cannot be replayed withreplay-epoch!. nilorfalsemeans 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
:dispatchor:dispatch-laterfrom inside the scope keep the overrides. - See Asserting on what would dispatch.
- Precedence, highest first: per call (
- 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:
- 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 withre-frame.test-helpersinstead.- 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'sact(), so the effects that commit scheduled have run too. React's production build has noact(), and there it commits without one. - It settles only what ran synchronously: a mount, or a
dispatch-syncinsidef. A queueddispatch, an HTTP reply or a timer has not happened when it returns; wait for those withpoll-until. - Where nothing renders live (the plain-atom and SSR adapters, and every JVM run) it runs
fand returns nil. With no adapter installed it raises:rf.error/no-adapter-installed. - React's
act()expects the test to setglobalThis.IS_REACT_ACT_ENVIRONMENTtotruewhile it drives React throughflush-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, becauseact()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.
- It names no adapter. The commit is the installed adapter's: Reagent drains its render queue, UIx runs React's
- Example:
Waiting for async work¶
poll-until¶
- Kind: function
- Signature:
- Description: Calls
predrepeatedly 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-infoon timeout. - CLJS: returns a
js/Promisethat resolves with the truthy value or rejects on timeout. Apredthat returns ajs/Promiseis awaited, and its resolved value is tested. Call it from an(async done …)test, whose suite fixture needs:async? true. - A
predthat 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-msand:labelin 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 calldoneonce, last. A.catchplaced afterdonereports a failure from a later namespace against this test and callsdonea second time. - See Observing the
:loadingstate, which uses it with a delayed HTTP reply and says why not to poll across a debounce window.
- JVM: synchronous. Returns the truthy value, or throws
- 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:
- Description: Records the trace events emitted while
bodyruns into an atom bound torecs-sym.optsis 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:shapeother than:flator: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:
- Description: Records what the always-on error or event stream emits while
bodyruns into an atom bound torecs-sym. It is the always-on counterpart ofwith-trace-recorder!: these streams also run in production builds.optsis 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
:errorsrecord is{:rf.error/id … :event … :event-id … :frame … :time … :exception … :elapsed-ms … :source-coord …},:rf.error/idbeing 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 fromrf.epoch/restore-epoch!orrf.epoch/replace-frame-state!; record those withwith-trace-recorder!. An error thrown to the caller, such as:rf.error/poll-until-timeout, produces none either, so catch that one instead. - An
:eventsrecord is{:event … :event-id … :frame … :time … :outcome … :elapsed-ms …}.:outcomeis:ok,:error(the handler or an interceptor threw),:rolled-back(a schema rejected the newapp-db),:flow-error(a flow's output threw) or:rejected(a:boundary? truehandler's:schemarefused the payload). An event whose handler sets:rf.trace/no-emit? trueproduces no record. - In both,
:eventhas already passed the off-box elision: a declared-sensitive argument reads:rf/redacted, and an oversized one a:rf.size/large-elidedmarker. The rest of the record is unprojected, which is what a test wants::exceptionis the raw throwable. - These streams have no public listener function, and
rf/register-listener!has no:eventsor:errorsstream: an application reads production records, projected, through a frame's:observabilitysink 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.