Skip to content

Testing

Choose the cheapest test that can prove the behaviour you care about.

Most Fresco application logic does not need a browser. Event handlers and subscriptions are plain functions, markup helpers return data, and a hook-free view body can run against fixture subscription values. Mount React only when the claim depends on React or the DOM, and use real browsers for facts that only a browser engine knows.

The test kit has two namespaces:

  • re-frame.fresco.test, usually aliased ht, for browser-free tests;
  • re-frame.fresco.test.mounted, usually aliased hm, for real React and DOM tests.

Both ship with Fresco and are on your classpath on either route, so there is nothing to add. Mounted tests need a build target that provides a document; see Set up the test kit.

The testing ladder

Level What it proves Mechanism
L0 Handler behaviour, subscription output, state transitions Pure function calls
L1 Intent values, prevent/navigate decisions, codecs, merge laws, macro expansion Plain data and property tests
L2 The semantic output of one hook-free view body ht/tree with injected subscription fixtures
L3 React lifecycle, hooks, context, refs, hosts, error boundaries, real DOM Mounted facade with Testing Library and user-event, on a target that has a DOM
L4 IME, caret, focus traversal, layout, hydration, browser performance Chromium, Firefox, and WebKit

A passing semantic-tree test does not prove React lifecycle behaviour, and a mounted DOM test does not prove cross-browser caret behaviour. Keep each claim at the level that can actually observe it.

Example feature

The examples use one todo row:

(ns todo.events
  (:require [re-frame.core :as rf]))

(rf/reg-event :todo/seed
  (fn [_cofx [_ todos]]
    {:db {:todos (into {} (map (juxt :id identity)) todos)}}))

(rf/reg-event :todo/toggle
  (fn [{:keys [db]} [_ id]]
    {:db (update-in db [:todos id :done?] not)}))
(ns todo.subs
  (:require [re-frame.core :as rf]))

(rf/reg-sub :todo/by-id
  (fn [db [_ id]]
    (get-in db [:todos id])))
(ns todo.views
  (:require [re-frame.fresco :as h]))

(h/defview todo-row [{:keys [id]}]
  (let [{:keys [title done?]} (h/sub [:todo/by-id id])]
    [:li
     [:label
      [:input {:type      :checkbox
               :checked   (boolean done?)
               :on-change [:todo/toggle id]}]
      title]]))

L0: handlers and subscriptions

Event handlers are ordinary functions over coeffects and an event vector. Load the registration namespace, obtain the handler, call it with literal values, and assert on the returned effects:

(ns todo.events-test
  (:require [clojure.test :refer [deftest is]]
            [re-frame.core :as rf]
            [todo.events]
            [todo.subs]))

(deftest toggle-flips-done
  (let [handler (:handler-fn
                 (rf/handler-meta {:kind :event :id :todo/toggle}))
        result  (handler
                 {:db {:todos
                       {7 {:id 7
                           :title "Buy milk"
                           :done? false}}}}
                 [:todo/toggle 7])]
    (is (true? (get-in result [:db :todos 7 :done?])))))

A subscription can be tested against an app-db value:

(deftest by-id-reads-one-todo
  (is (= {:id 7 :title "Buy milk" :done? false}
         (rf/compute-sub
          [:todo/by-id 7]
          {:todos
           {7 {:id 7
               :title "Buy milk"
               :done? false}}}))))

Both tests can run on the JVM. Most application behaviour belongs at this level, so most tests should too.

L1: helpers, intents, and other data

A helper that takes values and returns Hiccup is a plain function:

(defn priority-badge [level]
  [:span.badge
   {:data-level (name level)}
   (name level)])

(deftest badge-is-the-data-it-claims
  (is (= [:span.badge
          {:data-level "high"}
          "high"]
         (priority-badge :high))))

Use the same approach for:

  • an event intent;
  • [::h/prevent INTENT];
  • a route link's navigation decision;
  • codecs and round trips;
  • the owned-wins attribute merge.

A test does not need to mount and click a button merely to learn which event vector the button contains.

L2: one view body as a semantic tree

A defview cannot be called directly. ht/tree supplies a browser-free render context, resolves h/sub calls from a fixture map, and returns the hiccup the body returned, normalised, so a test asserts in the language the view is written in.

(ns todo.views-test
  (:require [clojure.test :refer [deftest is]]
            [re-frame.fresco.test :as ht]
            [todo.views :as views]))

(deftest row-renders-title-and-carries-the-toggle
  (let [tree
        (ht/tree
         [views/todo-row {:id 7}]
         {:subs
          {[:todo/by-id 7]
           {:id 7
            :title "Buy milk"
            :done? false}}})]
    (is (= "Buy milk"
           (ht/text
            (ht/find tree #(= :label (first %))))))

    (is (= [:todo/toggle 7]
           (:on-change
            (ht/attrs
             (ht/find tree #(= :input (first %)))))))

    (is (= [[:todo/toggle 7]]
           (ht/intents tree)))))

The head may be the defview or its underlying body function. No React element is created, and nothing mounts or paints.

The tree is hiccup

Every node is [head attrs & children], and attrs is always a map. The .class#id sugar is folded into :class and :id, nil attributes are dropped, :on-* intents stay in the map as the data you wrote, adjacent text is joined, and for results are spliced in place. A small body is often clearest asserted whole:

(is (= [:li {}
        [:label {}
         [:input {:type :checkbox :checked false :on-change [:todo/toggle 7]}]
         "Buy milk"]]
       tree))

Attribute values are what the author wrote: there is no px rule and no canonical style map. A function-valued attribute records as {:rf.ui/opaque :fn}, so a test can see that a callback is there without comparing functions.

Useful tree helpers include:

  • ht/find / ht/find-all — find nodes using a predicate over nodes;
  • ht/attrs — return a node's attribute map;
  • ht/text — collect its text;
  • ht/intents — collect event intents in the tree.

A node's head identifies it: a tag keyword for an element, :<> for a fragment, and :rf.ui/view for a child view call. A view call's attribute map carries :view-id, the "<ns>/<sym>" name that ht/view-name returns for the view (and the name React DevTools shows), beside the props the call passed. Take it from the var rather than typing the string, so a rename moves the test with the code:

(ht/find tree #(= (ht/view-name views/todo-row) (:view-id (ht/attrs %))))

If the tree contains an h/route-link, compare intents by membership rather than exact equality; see The intent stream carries more than your events.

The root is always a node

ht/tree returns a node, never nil. A body that returns one element roots in that element. A body that returns text, several forms, or nothing at all roots in a fragment. So a body whose whole return is nil, usually a when that did not fire, returns this, and (nil? tree) is false:

[:<> {}]

Assert (= [:<> {}] tree). Asserting the absence of the node you care about is stronger still, because it survives the body later gaining a wrapper:

(is (nil? (ht/find tree #(= :nav (first %)))))

Subscription fixtures

The fixture map replaces the subscription layer for this body. Fixture keys are query vectors compared by value, so these are different fixtures:

[:todo/by-id 7]
[:todo/by-id "7"]

A read without a fixture raises :rf.error/fresco-test-missing-read-fixture and names the missing query. The harness does not silently substitute nil or call the live subscription cache.

Child views stay as calls

L2 runs one body. A nested child such as:

[todo-row {:key id :id id}]

is recorded as the call it is, and its body does not expand:

[:rf.ui/view {:view-id "todo.views/todo-row" :key 7 :id 7}]

Assert the child's props at the parent call site and test the child separately, so each failure points at one view.

L2 refuses React-only behaviour

The harness raises and points to L3 when a body reaches a raw React element (:rf.error/fresco-test-react-is-opaque) or a defhost crossing (:rf.error/fresco-test-host-is-opaque). No hook dispatcher is installed, so a body that calls a React hook fails with React's own error instead; that body belongs at L3 too.

Do not build a fake hook dispatcher

A fake dispatcher can pass tests that real React fails under abandoned renders, StrictMode, or effect ordering. Split the semantic part from the React mechanics. Test the data at L2 and mount the mechanics at L3.

Assert a refusal by its id

When testing a refusal, assert its stable id rather than its message. A thrown Fresco error carries the id in ex-data under :rf.error/id:

(ns todo.refusal-test
  (:require [cljs.test :refer [deftest is]]
            [re-frame.fresco.test :as ht]))

(defn todo-count [_]
  [:span.todo-count "3 left"])

(defn footer [_]
  [:footer
   [todo-count {}]])

(defn refusal-id [f]
  (try
    (f)
    ::did-not-throw
    (catch :default e
      (:rf.error/id (ex-data e)))))

(deftest plain-defn-child-head-refuses
  (is (= :rf.error/fresco-test-plain-fn-head
         (refusal-id
          #(ht/tree [footer {}] {:subs {}})))))

Messages may change between releases; ids do not.

The test kit accepts a plain function as the root of ht/tree, because that is the body it is running. A plain function as a child head raises :rf.error/fresco-test-plain-fn-head; the same mistake in a mounted tree raises :rf.error/fresco-bad-head.

L3: mounted React and DOM

Use the mounted facade when the claim depends on React, hooks, context, refs, error boundaries, hosts, or real DOM nodes.

Each mount gets its own frame, app-db, queue, subscription cache, React root, and leak baseline. It also needs a document; see Where L3's DOM comes from.

(ns todo.views-mounted-test
  (:require [cljs.test :refer [async deftest is]]
            ["@testing-library/dom" :as tl]
            [re-frame.fresco.test.mounted :as hm]
            [todo.events]
            [todo.subs]
            [todo.views :as views]))

(deftest toggle-reaches-the-real-dom
  (async done
    (let [m
          (hm/mount!
           [views/todo-row {:id 7}]
           {:initial-events
            [[:todo/seed
              [{:id 7
                :title "Buy milk"
                :done? false}]]]})]
      (is (some? (tl/getByText (:container m) "Buy milk")))

      (hm/dispatch-and-settle! m [:todo/toggle 7])

      (is (true?
           (.-checked
            (tl/getByRole (:container m) "checkbox"))))

      (-> (hm/unmount! m)
          (hm/assert-clean!)
          (.then done)))))

The facade does not add a selector language. (:container m) is a real DOM node, so use Testing Library and user-event normally. After a user-event sequence, call (hm/settle! m) before asserting.

Call Behaviour
hm/mount! Mount a view under a fresh isolated frame and return a handle
hm/hydrate! Hydrate supplied server HTML; returns a promise of the handle
hm/rerender! Render a new element into the same root
hm/dispatch-and-settle! Dispatch into the mount's frame, run that event synchronously and commit the result. Follow-up work the router only queued is not waited for; use hm/settle-until!
hm/settle! Commit pending work after a user-event or other external interaction
hm/settle-until! Wait until a predicate holds, then settle; returns a promise of the handle. Use it for work the router has only queued, such as after a route-link click
hm/advance-clock! Advance the page-wide virtual clock and run due work
hm/bodies-run Call a function and return how many view bodies ran while it did. Settle inside the function, or the count is short
hm/unmount! Tear down the root
hm/assert-clean! After unmount, check that no subscription, reader edge, boundary registration, cached read set or frame survived compared with the pre-mount baseline

Mounted operations take the handle first and return it where chaining is useful. assert-clean! is asynchronous because it waits for pending work to finish before checking.

Mount options

hm/mount! takes these options, and hm/hydrate! takes the same plus :html. Any other key raises :rf.error/fresco-test-bad-option naming the accepted keys, so a misspelt option cannot be ignored in silence.

Option Meaning
:initial-events Setup events run into the new frame in order, before the first render; core's rf/make-frame option
:images The image composition the frame is built from, passed to rf/make-frame as given. Omit it for the default image
:container An existing element to render into. The default is a fresh <div> attached to document.body
:clock true installs the page-wide virtual clock while this mount is open

The default image covers every registration loaded, and it refuses to pick between two namespaces registering one [kind id]: using that id raises :rf.error/ambiguous-registration. An application that deliberately overrides a library registration builds its frames from disjoint ordered images instead, where the later image wins. Give the mount the same vector:

(hm/mount! [views/inbox {}]
           {:images         [library-image app-image]
            :initial-events [[:inbox/seed]]})

hm/shadow! takes :images too, and builds both of its frames from it.

Use the settle operations rather than React's act for page assertions. dispatch-and-settle! runs the event and commits the result before it returns, so the next line sees the DOM a user would see; act runs through a test scheduler that is not the browser's.

ht/canonical-dom serialises a mounted subtree so two pages can be compared; see Canonical DOM.

Use L3 for React claims

Examples include:

  • a real error boundary catching a real throw (Errors);
  • StrictMode double invocation or an abandoned render;
  • keyed insertion, deletion, and reorder against real nodes;
  • a foreign component's hooks, context, and refs (Interop);
  • Activity hide and reveal, including subscription release and reacquisition;
  • hydration through hm/hydrate! (SSR and hydration).

hm/assert-clean! requires that nothing it counts survives unmount: a retained subscription, or a frame the app made and did not destroy, is a bug; fix it rather than loosening the check. It does not see stray DOM listeners or timers.

Boundaries without React: re-frame.fresco.test.runtime

Tooling that inspects Fresco's runtime, such as a panel that lists mounted boundaries, needs live boundaries but not a React root. re-frame.fresco.test.runtime, usually aliased rt, provides that seam:

Call Behaviour
rt/render-and-commit! Run one body, or a defview, in a frame and commit it in React's place. Returns {:element … :reads … :release …}: the React element it rendered, the subscriptions it read, and the cleanup that takes the boundary down
rt/reset-bracket An :each fixture. After each test it waits for the runtime to settle, fails the test if any subscription, reader edge, boundary or cached read set survived, and only then resets the runtime
(ns my-tool.boundary-census-test
  (:require [cljs.test :refer [deftest is use-fixtures]]
            [re-frame.core :as rf]
            [re-frame.fresco :as h]
            [re-frame.fresco.substrate :as substrate]
            [re-frame.fresco.test.runtime :as rt]
            [re-frame.test-support :as test-support]
            [todo.subs]))

(use-fixtures :each
  (test-support/make-reset-runtime-fixture {:adapter substrate/adapter :async? true})
  rt/reset-bracket)

(deftest a-boundary-holds-what-it-read
  (rf/make-frame {:id ::app})
  (let [{:keys [reads release]}
        (rt/render-and-commit! ::app (fn [_] (h/sub [:todo/count]) nil))]
    (is (= #{[::app [:todo/count]]} reads))
    (release)))

The bracket checks before it resets because a reset empties the tables it would read: a test that resets first and checks afterwards passes whether or not its boundaries were released. A test that forgets (release) fails here.

rt/reset-bracket is a map fixture, and cljs.test will not mix map and function fixtures in one namespace, so the runtime fixture beside it is built in its map form with :async? true. List the bracket after it, as above: it then checks before that fixture's teardown runs.

L4: real browser engines

Use Chromium, Firefox, and WebKit for behaviour that depends on an actual browser engine:

  • IME composition;
  • caret restoration and selection behaviour;
  • focus traversal;
  • layout and scroll geometry;
  • hydration against real server bytes;
  • performance budgets.

The driver is Playwright, an npm package that also downloads the browsers:

npm install --save-dev playwright@1.59.1
npx playwright install chromium   # add firefox and webkit as you need them

One API exposes chromium, firefox and webkit, so a spec written once runs on all three. Pin the version: the downloaded browser decides what the check can see, and 1.59.1 is the version Fresco's own engine tests use.

Fresco's own L4 gate is a small worked example: a build, a server, a browser and a script, with no runner framework or config file. fresco/testbed/spec.cjs is a plain file of assertions, and serve-and-run-fresco-controlled-testbed.cjs compiles the testbed build, serves it, and runs that spec once per engine.

Controlled-input composition is a canonical L4 case (Controlled inputs). Performance scripts and budgets belong to Performance.

Prevent vacuous tests with a sabotage twin

A collection assertion can pass because the collection was accidentally empty. every? over no items is true.

Pin the population with an explicit count. For important instrumentation, also add a sabotage twin: deliberately change the input so the measurement moves. That proves the test can fail.

;; todo.views
(h/defview todo-list [_]
  [:ul
   (for [{:keys [id]} (h/sub [:todo/visible])]
     [todo-row {:key id :id id}])])

(deftest every-visible-todo-gets-a-row
  (let [tree
        (ht/tree
         [views/todo-list {}]
         {:subs {[:todo/visible] [{:id 1} {:id 2} {:id 3}]}})
        rows (ht/find-all tree #(= :rf.ui/view (first %)))]
    (is (= 3 (count rows)))
    (is (= [1 2 3]
           (mapv #(:id (ht/attrs %)) rows)))))

(deftest every-visible-todo-gets-a-row--sabotage-twin
  (let [tree
        (ht/tree
         [views/todo-list {}]
         {:subs {[:todo/visible] []}})]
    (is (= [:ul {}] tree))))

The list test proves that the parent calls one row per id with the expected props. The row's own test proves what a row renders.

Troubleshooting

Symptom Cause Fix
ht/tree raises and points to L3 The body reached a raw React element or a host Mount the view at L3; split out a hook-free semantic part when useful
ht/tree fails with React's invalid-hook error The body called a React hook, and L2 installs no hook dispatcher Mount the view at L3; split out a hook-free semantic part when useful
ht/tree raises :rf.error/fresco-test-missing-read-fixture The body read a subscription with no fixture Add a fixture for that exact query vector
ht/tree cannot inspect a defview head in an advanced build goog.DEBUG false removed the body property used by the development harness Run view tests in a development build, or pass the body function instead of the head
A plain test raises :rf.error/fresco-sub-outside-render A helper called h/sub without a render context Use L2 for a view body; use L0 for handlers and subscriptions
:rf.error/fresco-deferred-read-at-boundary An unforced delay reached a child view's props Force it in the body, or pass the realised value (Views and reads)
A mount without :images raises :rf.error/ambiguous-registration Two loaded namespaces register the same [kind id], and the default image will not pick a winner If one deliberately overrides the other, pass the application's ordered :images; otherwise rename one of them
hm/mount! throws :rf.error/fresco-test-bad-option An option key outside the mount's roster, often a misspelling Use a key the message lists (Mount options)
hm/mount! throws :rf.error/initial-events-step-failed A seed event's handler, interceptor or coeffect threw, or a coeffect it requires is missing Fix that step; :step-index and :event in the ex-data name it
The mounted page renders empty A seed event has no handler because the test does not require its namespace, so the step is skipped with an :rf.error/no-such-handler record rather than a throw Require the events namespace
hm/settle-until! rejects with :rf.error/poll-until-timeout The predicate never held within :timeout-ms (default 2000) Check what the predicate reads; pass :label to name the wait
hm/assert-clean! fails A subscription, reader edge or frame survived unmount Fix the leak; a foreign host that keeps a callback, or a frame the app made and did not destroy, is a common cause (Interop)
Data test passes but mounted test fails React lifecycle, effect order, StrictMode, or commit timing changed the result Treat the mounted result as authoritative for React behaviour
An L1 equality on a helper's Hiccup sees an unexpected nil A when in the helper returned nil; it renders nothing but is still in the authored data Include that nil in the expected value, or filter it before comparing
(nil? (ht/tree ...)) fails on a body that renders nothing The root is always a node; a body returning nil roots in an empty fragment Assert (= [:<> {}] tree) (The root is always a node)
Every hm call compiles but nothing mounts The test lane has no document Run L3 on a :browser-test target (Where L3's DOM comes from)
An ht/intents equality reds when an unrelated test is added The tree holds a route link, whose navigate decision carries a per-call probe frame id Compare by membership, or filter to the heads you own (The intent stream carries more than your events)
A collection test remains green with an empty list The assertion is vacuously true Assert the count and add a sabotage twin

When not to test through a view

Test a handler when the claim is about state changes. Test a subscription when the claim is about a derived value. A view test that dispatches an event and then asserts on app-db mixes several contracts and can fail for unrelated reasons.

Do not claim more than the level proves:

  • L2 does not prove React lifecycle;
  • a semantic tree does not prove server or hydration bytes;
  • a DOM test in one engine does not prove cross-browser IME or focus;
  • a timing assertion is not a performance test until it follows the method in Performance.

Set up the test kit

Put the test kit on the classpath

There is nothing to add, on either route. Both namespaces ship with day8/re-frame2-fresco: the published jar carries them, and a :local/root checkout puts the kit's source root, test_kit/src, on your classpath through the artifact's own :paths. With the :local/root setup as it stands, every ht and hm require on this page resolves.

The kit stays out of your production bundle because no shipping namespace requires it: a build that never requires ht or hm compiles none of the kit.

L3 also uses Testing Library, which is an npm package: npm install --save-dev @testing-library/dom, plus @testing-library/user-event if your tests drive real interactions.

Where L3's DOM comes from

L0–L2 never touch a document, so any target runs them. hm/mount! renders through react-dom/client, which needs a real document. Nothing on the classpath supplies one, and Testing Library only queries a DOM; it does not create one. On a shadow-cljs :node-test build there is no document, so every hm call compiles and then fails at run time.

There are two options:

  • A :browser-test target compiles the suite into a page and runs it in a real engine. This is what Fresco's own mounted suites use, and it is the recommendation, because L3 exists to prove React and DOM facts.
  • A DOM shim on :node-test (jsdom or happy-dom, installed as globals before the suite loads) is cheaper and enough for structural assertions. It is least reliable about focus, selection and layout, which are L4's subject anyway.

Split the two lanes by namespace suffix, so the browser-free bulk of the suite never pays for a browser:

;; shadow-cljs.edn: the :builds map, beside :app
{:builds
 {:node-test    {:target    :node-test
                 :ns-regexp "-cljs-test$"
                 :output-to "out/node-test.js"}

  :browser-test {:target    :browser-test
                 :ns-regexp "-dom-cljs-test$"
                 :test-dir  "out/browser-test"}}}

Name a mounted test's namespace ...-dom-cljs-test and a browser-free one ...-cljs-test.

The regexes overlap: -cljs-test$ also matches -dom-cljs-test, so a mounted file compiles on the Node lane as well. That is often useful, but each mounted test then has to skip visibly when there is no document:

(defn- browser? [] (exists? js/document))

(deftest toggle-reaches-the-real-dom
  (async done
    (if-not (browser?)
      (do (println "SKIP (no document): the toggle on a real checkbox")
          (done))
      (run-the-mounted-body done))))

The printed skip keeps the Node run honest: a mounted test that silently passes without a document reports coverage it never had.

Advanced

Name the equality being tested

Equality Level Claim
Authored data L1 This function returned this value
Semantic tree L2 Under these reads, this body means this
Intent stream L2 or L3 These interactions produced these events in this order
Canonical DOM L3 Two mounted implementations produced the same page structure
React server bytes Server test The server emitted these exact bytes
Hydrated behaviour L4 A real engine adopted and ran the page correctly

Canonical DOM

ht/canonical-dom serialises a live DOM subtree with element attribute names sorted. innerHTML preserves insertion order, so two equivalent pages can produce different strings solely because props were applied in a different order. It also removes the development annotations data-rf2-source-coord and data-rf-view, so declaration locations do not make otherwise equivalent pages differ.

Use canonical DOM when comparing:

  • before and after a refactor;
  • a Fresco port with its Reagent original (Migrating from Reagent);
  • a React island with the interpreted subtree it replaced (Islands).

Virtual clock behaviour

A virtual clock is opt-in:

(hm/mount! [view] {:clock true})

The clock is page-wide: while any mount that asked for it is open, every setTimeout, setInterval and Date.now on the page is virtual, and hm/advance-clock! on one handle fires every timer that falls due, whichever mount armed it.

hm/advance-clock! advances Date.now, setTimeout, and setInterval. This matters because retention often compares a deadline with Date.now; firing a timer without moving the clock would leave the deadline unexpired.

The clock does not advance:

  • requestAnimationFrame;
  • promises or microtasks;
  • performance.now;
  • the Date constructor;
  • timer functions captured before the virtual-clock window opened, including React scheduler references captured at module load.

Calling hm/advance-clock! on a handle mounted without {:clock true} raises. Use it for work that has an actual duration; use hm/settle! for work that does not.

Migration shadow tests

hm/shadow! is a development-only migration harness. It mounts a Fresco view and its Reagent original side by side, each in its own seeded frame, and compares canonical DOM, live control state and intent streams whenever you call its :checkpoint!. You drive both mounts yourself, with Testing Library and user-event, and wait on conditions with hm/settle-until!, so a flow that navigates or waits on an async reply can be compared too. Its full use belongs to Migrating from Reagent.

The intent stream carries more than your events

ht/intents returns every event vector in the tree, not only your application's. A tree holding an h/route-link also holds routing's click decision, which embeds its frame. Under ht/tree that frame is a fresh probe keyword per call, numbered in the order the tests ran:

[:re-frame.fresco.impl.intent/navigate
 {:frame   :re-frame.fresco.test/probe-7
  :payload [:rf.route/navigate {:url "/profile/jane" ...}]
  :native? false
  :veto    nil}]

So the exact-equality assertion in L2 is right for the todo row, whose tree has no link, and fragile for any tree that has one: adding a test above it renumbers the probe and fails it. Where a link is in play, assert what you actually meant — that your intent is offered:

(is (contains? (set (ht/intents tree)) [:todo/toggle 7]))

or, when the order of your own events is the claim, filter to the heads you own before comparing:

(is (= [[:todo/toggle 7] [:todo/delete 7]]
       (filterv #(= "todo" (namespace (first %))) (ht/intents tree))))

For the link itself, assert its :href. Routing synthesised it from :to and :params, which is the fact worth pinning, and it does not move.