Skip to content

Inspecting and testing

Test the login table with machine-transition before running its effects. When a live flow behaves differently, inspect its snapshot and the event that changed it.

Unit-test with machine-transition

A transition is a pure function of the definition, a snapshot and a trigger. The first machine's test imports login-flow and calls rf.machines/machine-transition on it directly:

Cover a refused trigger as well as a successful one:

(ns app.login-test
  (:require [clojure.test :refer [deftest is]]
            [re-frame.machines :as rf.machines]
            [app.login :refer [login-flow]]))

(deftest empty-credentials-do-not-submit
  (let [before {:state :idle :data {:attempts 0 :error nil}}
        result (rf.machines/machine-transition
                 login-flow before [:auth.login/submit {:email "" :password ""}])]
    (is (= :ok (:status result)))
    (is (false? (:handled? result)))
    (is (= before (:snapshot result)))
    (is (empty? (:fx result)))))

Add cases for unknown triggers, the last permitted retry and a thrown callback when the table has one. The result is one plain map:

{:status :ok  :snapshot {:state … :data … :tags …} :fx [[:rf.http/managed …] …] :handled? true}   ;; success
{:status :error :error {:kind :rf.error/machine-action-exception :exception … …}} ;; a guard or action threw
  • :snapshot is the next snapshot; an event no transition takes returns :status :ok with the snapshot unchanged, :fx [] and :handled? false. A transition that fires and changes nothing reports :handled? true, so a test can tell a declined event from an accepted no-op.
  • :fx is the effects vector in emission order.
  • :error carries the diagnostics when a guard, action or :data fn throws (:kind :rf.error/machine-action-exception, with the :exception and the throwing ref) or a runaway :always / :raise cycle hits its depth limit (:kind :rf.error/machine-always-depth-exceeded or :rf.error/machine-raise-depth-exceeded). A failure carries no snapshot — nothing was committed.
  • Mistakes in the call itself — a malformed :state, a guard or action keyword with no entry in the definition — throw an :rf.error/* ex-info rather than returning :status :error, exactly as reg-machine would.

Effects are asserted as data: the HTTP request is not performed, and the test inspects the returned :fx description. This call starts from the snapshot you provide; it does not boot a singleton, execute timers or actors, or run the registered schema-validation boundary. Test those behaviors through a frame.

Three useful test levels

Level What it tests When to use
machine-transition table logic, guards, action effects default
unregistered handler (make-machine-handler) the event handler reg-machine would register, built without registering it rare
registered test frame dispatch, tracing, spawn/destroy, actor messaging actor-heavy integration

Keep most tests at the first level. It is fast, deterministic, and does not require a browser.

The third level runs the real pipeline in a fresh frame, so it is the one that exercises spawned actors and replies. Stub the HTTP the table issues (Test a pipeline run has the recipe):

(ns app.login-frame-test
  (:require [clojure.test :refer [deftest is use-fixtures]]
            [re-frame.core :as rf]
            [re-frame.http.test-support :as http-test-support]
            [re-frame.substrate.plain-atom :as plain-atom]
            [re-frame.test-support :as ts]
            [app.login]))                  ;; registers :auth.login/flow

(use-fixtures :each (ts/make-reset-runtime-fixture {:adapter plain-atom/adapter}))

(deftest failed-login-shows-the-error
  (http-test-support/with-request-stubs
    {[:post "/api/login"] {:reply {:failure {:kind :rf.http/http-4xx :status 401}}}}
    (fn []
      (rf/dispatch-sync [:auth.login/flow [:auth.login/submit {:email "a@b.com"
                                                               :password "x"}]])
      (let [snap (rf/subscribe-once [:rf/machine :auth.login/flow])]
        (is (= :error-shown (:state snap)))
        (is (= 1 (get-in snap [:data :attempts])))))))

What failure means

At the pure testing surface, a guard or action that throws yields the :status :error result above rather than an exception from the test call.

At runtime, the same failure aborts the macrostep atomically. The previous snapshot remains visible. The error is reported as :rf.error/machine-action-exception (a thrown guard does not fall through to the next candidate).

Read the live snapshot

@(rf/subscribe [:rf/machine :auth.login/flow])
;; => {:state :submitting
;;     :data  {:attempts 1 :error nil}
;;     :tags  #{:auth/busy}}

The snapshot is nil before the first event addressed to a singleton. A spawned actor's snapshot exists from the moment it is spawned. For busy, read-only, connected and similar questions, ask a tag instead; [:rf.machine/has-tag? id tag] returns false for an unknown or not-yet-started machine.

Use Xray

When a flow misbehaves, Xray's Machine Inspector shows which event moved the machine there, read from the trace stream.

A good debugging loop:

  1. reproduce the behaviour;
  2. click the event row in Xray;
  3. open the machine inspector;
  4. compare before-state and after-state;
  5. inspect the guard and action records;
  6. if the topology is surprising, inspect the static machine definition.

Troubleshooting

Symptom Cause Fix
machine-transition is unresolved Required from re-frame.machines, not rf/ (:require [re-frame.machines :as rf.machines])
Guard threw and a later candidate did not run Thrown guards abort the macrostep Fix the guard; do not rely on fall-through after a throw
Test expected :rf.http/managed and got none :entry did not run, or the table under test is not the defmachine value Import login-flow; start from :idle so :submitting entry fires

A nil snapshot from [:rf/machine id] is covered in First machine → Troubleshooting.

Advanced

Trace records

Machines emit trace records through the standard trace bus. There is no separate machine log.

You will most often see records for:

  • transition selected (:rf.machine/transition);
  • guard evaluated (:rf.machine/guard-evaluated);
  • action ran (:rf.machine/action-ran);
  • timer scheduled, fired, cancelled, or stale (:rf.machine.timer/scheduled, :rf.machine.timer/fired, :rf.machine.timer/cancelled, :rf.machine.timer/stale-after);
  • actor spawned, finished, or destroyed (:rf.machine.spawn/spawned, :rf.machine/done, :rf.machine/destroyed);
  • unhandled event no-op (:rf.machine.event/unhandled-no-op).

A guard trace tells you:

{:operation :rf.machine/guard-evaluated
 :tags {:actor-id :auth.login/flow
        :guard-id :under-retry-limit
        :state    :submitting
        :outcome  :pass}}   ;; :pass | :fail | :threw

An action trace tells you:

{:operation :rf.machine/action-ran
 :tags {:actor-id  :auth.login/flow
        :action-id :issue-request
        :phase     :entry
        :outcome   {:fx [[:rf.http/managed …]]}}}   ;; the return value; :ok when it returns nil; :rf.error/action-threw

Those records are what Xray renders. You can also tap the stream yourself in development with (rf/register-listener! :trace …).

A frame also keeps its recent trace events, and rf/trace-buffer returns them. The cell below runs the login table against a server that fails every request, and lists the newest guard, action and transition records under the snapshot. Sign in three times: :under-retry-limit passes on the first two failures, and on the third it fails, so the unguarded candidate moves the machine to :locked-out.

(require '[re-frame.core :as rf]
         '[re-frame.http.managed]
         '[re-frame.http.test-support])

;; The tutorial's login table, without its deadline and session handler.
(rf/reg-machine :auth.login/flow
  {:initial :idle
   :data    {:attempts 0 :error nil}

   :guards
   {:form-valid?
    (fn [{[_ creds] :event}]
      (and (seq (:email creds)) (seq (:password creds))))
    :under-retry-limit
    (fn [{data :data}] (< (:attempts data) 2))}

   :actions
   {:clear-error
    (fn [_] {:data {:error nil}})
    :record-error
    (fn [{data :data [_ {:keys [error]}] :event}]
      {:data (-> data
                 (update :attempts inc)
                 (assoc  :error (or (:message error) "Login failed.")))})
    :issue-request
    (fn [{[_ creds] :event}]
      {:fx [[:rf.http/managed
             {:request    {:method :post :url "/api/login" :body creds
                           :request-content-type :json}
              :request-id :auth.login/request
              :decode     :json
              :on-success [:auth.login/flow [:auth.login/success]]
              :on-failure [:auth.login/flow [:auth.login/failure]]}]]})}

   :states
   {:idle
    {:on {:auth.login/submit {:target :submitting
                              :guard  :form-valid?
                              :action :clear-error}}}
    :submitting
    {:tags  #{:auth/busy}
     :entry :issue-request
     :on    {:auth.login/success :authed
             :auth.login/failure [{:target :error-shown
                                   :guard  :under-retry-limit
                                   :action :record-error}
                                  {:target :locked-out
                                   :action :record-error}]}}
    :error-shown
    {:on {:auth.login/submit {:target :submitting
                              :guard  :form-valid?
                              :action :clear-error}}}
    :authed     {:meta {:terminal? true}}
    :locked-out {:meta {:terminal? true}}}})

(def machine-ops
  #{:rf.machine/guard-evaluated :rf.machine/action-ran :rf.machine/transition})

(defn summary [{:keys [operation tags]}]
  (case operation
    :rf.machine/guard-evaluated [(:guard-id tags) (:outcome tags)]
    :rf.machine/action-ran      [(:action-id tags) (:phase tags)]
    :rf.machine/transition      [(get-in tags [:before :state]) '-> (get-in tags [:after :state])]))

(rf/reg-view inspect-view []
  (let [snapshot @(subscribe [:rf/machine :auth.login/flow])
        ;; The trace ring is not reactive. This view re-renders when the snapshot changes.
        records  (->> (rf/trace-buffer :auth.login/inspect {:flat true})
                      (filter #(machine-ops (:operation %)))
                      (take-last 7))]
    [:div
     [:button {:on-click #(dispatch [:auth.login/flow
                                     [:auth.login/submit {:email "a@b.com" :password "x"}]])}
      "Sign in"]
     [:p "snapshot: " (pr-str (some-> snapshot (select-keys [:state :data :tags])))]
     [:ol (for [r records]
            ^{:key (:id r)} [:li (pr-str (:operation r)) " " (pr-str (summary r))])]]))

;; :fx-overrides fails every request this frame sends. A real app leaves it out.
[rf/frame-root {:id :auth.login/inspect
                :fx-overrides {:rf.http/managed :rf.http/managed-canned-failure}}
 [inspect-view]]

Jump to source with handler-meta

Machine guards and actions are addressable through handler metadata.

(rf/handler-meta {:source :store :kind :machine-guard :id [:auth.login/flow :under-retry-limit]})

(rf/handler-meta {:source :store :kind :machine-action :id [:auth.login/flow :issue-request]})

In development this can include captured source, file, line, and handler function metadata. Production builds elide development-only source details.

Testing registered definitions

If a machine is already registered and you want its registered definition, read it off the registration. There is no machine-meta accessor: a machine is an :event registration carrying :rf/machine? true, and its spec is stored under the reserved :rf/machine key.

(rf.machines/machine-transition
  (:rf/machine (rf/handler-meta {:source :store :kind :event :id :auth.login/flow}))
  snapshot
  trigger)

Most tests should import the transition table value directly. Use registered metadata when the registration itself is part of what you are testing.