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, 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. 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: per-frame app 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 each loaded artefact's state (flows, schemas, machines, routing, resources, http, epoch); 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 and the per-frame app schemas to the snapshot and drops every frame again. - Per-frame app schemas are cleared at every reset and restored afterwards, so a schema registered against a frame at namespace load is not checked inside a test body. Register the schemas a test needs in
:init-fnor in the test itself. - Resources. Loading
re-frame.resources.test-supportpublishes the resources reset,:resources/reset-resources!. At each reset it clears every resource, mutation and resource-scope registration, together with the resource caches, timers, work ledger 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), building the default image fails with:rf.error/image-duplicate-idfor 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 thatrf/make-frameassembles a frame's default image from: registrations a test makes stay visible to frames created later, and two test namespaces registering the same id can makemake-framefail with:rf.error/image-duplicate-id.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, read as anilapp-db rather than raising, so the assertion fails unlessexpected-valisnil.- 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:
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.
On the JVM the macros resolve through the ordinary (:require [re-frame.test-support :as ts]). On CLJS the namespace does not self-require its macros (unlike re-frame.core), so a CLJS test file also needs (:require-macros [re-frame.test-support :refer [with-trace-recorder! with-emit-recorder!]]), or :as ts in :require-macros for alias-qualified use.
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{:error … :event … :event-id … :frame … :time … :exception … :elapsed-ms … :source-coord …},:errorbeing 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/restore-epoch!orrf/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 :error @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,with-fx-overrides,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.