Test a view¶
You've tested the handler and the pipeline run. Now the view: the right structure comes out, the right text shows for a given state, and the right handler is wired to the right button.
No browser for this one either. A view is a pure function that returns hiccup — plain data — so a view test is a function call and a tree walk, and it runs on the JVM in milliseconds.
A view test calls the function and walks the returned data — no DOM, no JSDOM, no
act().
One honest framing before the recipe: most "view bugs" are data bugs. A view holds no state and decides nothing, so when the screen is wrong, the culprit is nearly always the subscription or handler upstream — pure functions with cheaper tests (Views makes the case). A view test is for what a view genuinely owns: its structure, its text, and its wiring. That's the whole list.
The toolkit is re-frame.test-helpers — pure walks over hiccup, catalogued in the API reference — alongside the re-frame.test-support fixtures you already use:
(ns my-app.views-test
(:require [clojure.test :refer [deftest is use-fixtures]]
[re-frame.core :as rf]
[re-frame.test-support :as ts]
[re-frame.test-helpers :as th]
[my-app.counter :as counter])) ;; the app namespace under test
(use-fixtures :each
(ts/make-reset-runtime-fixture {:adapter my-app/test-adapter ;; your substrate adapter
:init-fn counter/install!})) ;; reg-event / reg-sub / views
make-reset-runtime-fixture seats the ambient :rf/default frame when given an :adapter, runs your registrations once via :init-fn, and snapshots/restores the registrar around every test. A purely presentational test (§1) needs neither key — an adapter-less (ts/make-reset-runtime-fixture {}) suffices — but the connected tests below rely on the seated frame.
1. Call it, walk it¶
Give the nodes you'll assert on a stable address at the view site — th/testid builds an attrs map carrying :data-testid — then call the view like any function and read the tree it returns:
;; The view under test — a plain function of its arguments.
(defn price-row [{:keys [label price]}]
[:tr (th/testid "price-row")
[:td label]
[:td (th/testid "price-cell") "$" price]])
(deftest price-row-shows-the-price
(let [tree (price-row {:label "Widget" :price "12.50"})]
(is (= "$12.50" (th/text-content (th/find-by-testid tree "price-cell"))))))
find-by-testid returns the first node carrying that :data-testid; text-content collects the string leaves under it. Their generic siblings — find-by-attr, find-all-by-testid, find-by-testid-prefix, attrs, children — cover lists and custom attributes ("every node whose testid starts with row-").
What about a view that renders another view? It comes back as a component reference — [cart-line item], a vector whose head is a function, not a tag. The finders and text-content expand those references as they walk, so asserting through a child view just works. And when you want the fully-expanded tree as a value — to let-bind once for several assertions, or to walk by hand — th/expand-tree is the same expansion as a standalone step.
Coming from React Testing Library?
find-by-testid / text-content are getByTestId / textContent — except the "render" was a plain function call, so there's no JSDOM to stand up and nothing to clean up. The query API is deliberately smaller: you're walking a value, not a live document.
2. Views that subscribe: the reset fixture¶
A presentational view takes data as arguments. A connected view subscribes and dispatches, so it needs a frame in scope. There's no bespoke view-fixture macro — the reset fixture at the top already gives you one: :adapter seats the ambient :rf/default frame and :init-fn runs your registrations. The view test is then just dispatch, call the view, walk the tree:
(deftest counter-increments-in-the-view
(rf/dispatch-sync [:counter/inc])
(rf/dispatch-sync [:counter/inc])
(is (= "2" (th/text-content
(th/find-by-testid (counter/main) "counter-display")))))
dispatch-sync drains before the assertion, so calling counter/main returns the freshly-rendered tree; find-by-testid + text-content read the value under test. The two dispatches in that body are the action under test — the counter incrementing is the point. When a view instead needs state built before the action (a populated cart, a signed-in user), seed it once — a dispatch-sync in the :init-fn, or an :ambient-frame-scoped make-frame with :initial-events [[:cart/seed-items …]] per the construction script make-frame takes. The body then holds only the interaction being tested.
The trap this composition already closes is the one Test an event handler warns about: :install registrations land in the process-global registrar, and make-reset-runtime-fixture snapshots/restores it around every test, so one test's registrations can't leak into the next.
3. Drive the wiring¶
The last thing a view owns is the connection from a node to its dispatch. th/invoke-handler finds the handler attached at an event attribute and calls it — so the test proves the button is wired, not just present:
(deftest inc-button-is-wired
(let [btn (th/find-by-testid (th/expand-tree (counter/main)) "counter-inc")]
(th/invoke-handler btn :on-click)) ;; runs the attached fn — the dispatch fires
(is (ts/poll-until
#(= "1" (th/text-content
(th/find-by-testid (counter/main) "counter-display")))
{:label "counter reached 1"})))
Two details carry this test.
First, invoke-handler throws when the node has no handler under that key. A missing handler is almost always the bug you're hunting, so it refuses to pass silently.
Second, the settle uses ts/poll-until, not a straight walk. The invoked :on-click fires a plain dispatch, which queues rather than draining synchronously, so the test polls the re-rendered view against a bounded deadline (loud timeout carrying :rf.error/poll-until-timeout). The same form covers any async settle whose outcome is visible in the view — an HTTP reply, a machine :after transition, a scheduled event. On CLJS, poll-until returns a js/Promise — compose it with cljs.test/async. For a synchronous run, walking the tree straight after dispatch-sync is enough.
When you want more than hiccup¶
Three neighbouring tools pick up where the tree walk stops:
- Rendered markup — when the assertion is about the HTML string a view produces (attribute serialisation, SSR output),
render-to-stringis the complementary path; seere-frame.ssr. - A real DOM — when you genuinely need React in the loop (a ref, a portal, an imperative child), mount under your adapter and settle updates with its
flush-views!test helper; Use UIx, Helix, or reagent-slim covers the shape. This is the rare case, not the default. - A view's states — "show this view empty, loading, error, and loaded" is not a tree-walk job; it's Story's whole purpose: named variants in isolated frames, promotable into tests.
When not to test a view¶
A confession to close on: I don't write many view tests. There, I said it.
Every test you write is a ball and chain you must forevermore drag about, so each one has to pay its way — and a view test that re-proves upstream logic doesn't. If the assertion is really "the sort order is right" or "the total is correct", that's a subscription test — cheaper, and it fails at the function that owns the logic. If it's "the state changed correctly", that's a handler test. A view test earns its keep only when the thing under test is the view's own contribution: structure, text, wiring.
Most views are boring enough — deliberately — that they need no test at all. Boring is the goal.