Skip to content

4. The variant is a test

You already wrote setup and assertions, so you are closer to a test than you may have noticed. This chapter shows the Test mode UI and the three execution verbs: rf.story/run, rf.story/is, and rf.story/explain. The useful idea is that a variant does not need a second encoding to become a regression test.

The reveal

Look again at the error variant:

(rf.story/reg-variant :story.login/error
  {:setup  [...]
   :script [[:dispatch-sync [:rf.assert/state-is :login/flow :error]]
            [:dispatch-sync
             [:rf.assert/sub-equals
              [:login/error]
              "Invalid credentials."]]]
   :tags #{:dev :docs :test}})

It has preconditions. It runs behaviour. It records assertions. That is a test. It also renders in the workshop, appears in Docs mode, can be shared, and can be inspected with Xray. Same artifact, different faces.

Test mode showing the login error variant run, its runner, status summary, assertions, and promotion action.

Test mode

The Test tab runs the selected variant and shows:

  • the runner used;
  • the evidence required;
  • pass/fail/cannot-run counts;
  • one row per assertion;
  • the step debugger;
  • promotion controls for turning a useful run into a regression variant.

Promotion turns the run in front of you into a named regression variant that fails for the reason this one failed. It captures the program the run executed: its dispatches or, when the script dispatches nothing, the variant's own stepped script. Every variant of the login-form testbed is that second shape (a :setup precondition plus [:assert …] checkpoints), and each one promotes from Test mode. By default the promoted variant extends the one you ran, so it inherits that variant's setup, and it carries that variant's own :assertions and :checks and its full script, checkpoints included.

The sidebar also tracks test status, so a state matrix can tell you which variants have passed without opening each one.

This is not a replacement for unit tests. It is a way for the examples you already maintain to participate in the test suite.

The three verbs

The public execution surface has three verbs:

(rf.story/run     target opts) ; returns a future/promise of the run result
(rf.story/is      target opts) ; reports through clojure.test / cljs.test
(rf.story/explain target opts) ; returns the compiled plan explanation

target can be a registered variant id:

(rf.story/run :story.login/error)

or an inline plan:

(rf.story/run
  {:setup [[:login/flow [:login/dismiss]]]
   :script [[:dispatch-sync
             [:rf.assert/state-is :login/flow :idle]]]})

Inline plans are useful for one-off tests that do not deserve a permanent place in the Story sidebar. Registered variants are useful when the state should be visible, documented, reviewed, or shared.

Using Story from tests

On the JVM, rf.story/is blocks until the run resolves and reports per assertion:

(ns my-app.login-stories-test
  (:require [clojure.test :refer [deftest]]
            [re-frame.story :as rf.story]
            [my-app.stories]))

(deftest login-error-story-passes
  (rf.story/is :story.login/error))

In CLJS, runs are async, so use the usual cljs.test/async shape:

(ns my-app.login-stories-test
  (:require [cljs.test :refer-macros [deftest async]]
            [re-frame.story :as rf.story]
            [my-app.stories]))

(deftest login-error-story-passes
  (async done
    (-> (rf.story/is :story.login/error)
        (.then (fn [_result] (done))))))

If you want the raw result instead of test reports, call rf.story/run and inspect the resolved map.

The run result

Every runner returns one result shape. The important keys are:

{:status     :pass        ; :pass | :fail | :cannot-run | :error
 :assertions [...]
 :checks     [...]
 :app-db     {...}
 :effects    [...]
 :warnings   [...]
 :epoch-tape [...]        ; evidence source, when retained
 :narrative  [...]        ; scrubbable projection
 :plan-hash  "..."
 :run-hash   "..."}

The top-level :status is not allowed to report :pass while the run contains unconsumed failure evidence. Schema failures, assertion failures, errors, and cannot-run rows all roll up into the status. The whole point is to kill the classic false green: a test that says pass because the one place you looked was green while the evidence elsewhere was red.

Explain

rf.story/explain does not run the variant. It shows what the variant becomes after inheritance, composition, args, checks, setup, and script are normalized.

That matters the first time a composed variant surprises you. Instead of guessing which parent or fragment contributed a field, read the explanation: source chain, merge decisions, final setup order, final script order, runner requirements, tags, platforms, and source coordinates.

You can see the same explanation in the Story inspector. It is boring and explicit. Composition without a receipt is just global state wearing a clever moustache.

Checks and assertions

Assertions in :script are checkpoints. They run at that point in the script.

Assertions in :assertions are terminal. They run after the script settles.

Checks are named assertion packs:

(rf.story/reg-check :check/no-runtime-warnings
  {:assertions [[:rf.assert/no-warnings]]})

(rf.story/reg-variant :story.login/error
  {:checks [:check/no-runtime-warnings]
   :setup  [...]
   :script [...]})

Checks are the inheritable expectation form. Ordinary assertions are local to the variant. That rule keeps a parent variant from silently forcing its verdict onto every child.

Accessibility beside the example

The right-hand rail carries an a11y panel. It runs axe-core against the variant on the canvas, and only the variant: Story's own chrome is excluded.

Press run. The first time, the panel asks before it loads anything, because axe-core@4.10.0 comes from a public CDN. Press enable axe-core + scan to approve once per browser. The choice is remembered, and no variant state leaves the browser.

The panel then reports two numbers: how many violations axe found in the variant, and how many checks it left incomplete. An incomplete check is one axe could not decide, which a person has to look at. A login card can read no violations while axe leaves its colour-contrast checks incomplete, because it could not determine the background colour behind the text. The panel lists those checks under their own heading, and reads "no violations, nothing incomplete" only when both numbers are zero. An incomplete check is not a failure, but it is not a pass either: report both numbers. A clean bill never absorbs incomplete findings.

An agent reads the same stored result through read-a11y-violations. That tool does not run a scan: it returns what the panel last stored, with the violations under :violations and the incomplete checks beside them under :incomplete. It also needs the browser host, so the story-mcp stdio server answers capability-unavailable rather than an empty list. Chapter 9 names the two hosts.

You now have a variant that renders, documents itself, and runs. The next problem is how to author scripts without hand-writing every click and wait.