Skip to content

Testing routes

Routing tests need no browser. route-url and match-url are pure functions you call directly; a navigation is an event you dispatch into a test frame; the active route is a subscription you read. A test frame is not :url-bound?, so nothing touches the address bar.

The setup is the one the core testing pages use: a JVM test namespace that loads the app's route registrations, plus the reset fixture, which restores the registrar and the routing state around each test:

;; test/app/routing_test.clj
(ns app.routing-test
  (:require [clojure.test :refer [deftest is use-fixtures]]
            [re-frame.core :as rf]
            [re-frame.routing :as rf.routing]
            [re-frame.schemas]                               ;; turns on route schema validation
            [re-frame.substrate.plain-atom :as plain-atom]   ;; the JVM substrate a frame needs
            [re-frame.test-support :as ts]
            [app.core]))                                     ;; registers the routes, events and subs

(use-fixtures :each
  (ts/make-reset-runtime-fixture {:adapter       plain-atom/adapter
                                  :ambient-frame nil}))   ;; each test makes its own frame

app.core is the tutorial's src/app/core.cljc. The JVM loads .cljc files and never .cljs, and it skips the #?(:cljs …) branches where the tutorial keeps the Reagent adapter and the mount code, so requiring the namespace registers the app without a browser (see Set up the test runner). re-frame.schemas ships in day8/re-frame2-schemas, so the test classpath needs that artefact beside day8/re-frame2-routing.

The URL and navigation tests use the tutorial's articles reader. They assert on routes and subscriptions directly, without mounting its views.

URLs

route-url builds a URL from an address, and match-url turns a URL back into one:

(deftest article-urls-round-trip
  (is (= "/articles/intro"
         (rf.routing/route-url {:to :app/article :params {:slug "intro"}})))
  (is (= "/articles?tag=ssr#top"
         (rf.routing/route-url {:to :app/articles :query {:tag "ssr"} :fragment "top"})))

  (let [m (rf.routing/match-url "/articles?tag=ssr")]
    (is (= :app/articles (:route-id m)))
    (is (= "ssr" (get-in m [:query :tag]))))   ;; a declared key arrives as a keyword

  (is (nil? (rf.routing/match-url "/no/such/page"))))   ;; a miss is nil, not an exception

A missing or nil path param makes route-url throw. Here that is :rf.error/route-url-validation, because the route's :params schema rejects it first; on a route with no :params schema it is :rf.error/missing-route-param. A nil query value is dropped instead, which is what lets a filter link leave ?tag= out when no tag is chosen. If the app relies on that, pin it:

(is (= "/articles" (rf.routing/route-url {:to :app/articles :query {:tag nil}})))

Schemas always coerce: an :int param arrives as a number. They validate only when re-frame.schemas is loaded, which is why the test namespace requires it; then a URL whose values fail the schema comes back from match-url with :validation-failed? true.

Dispatch a navigation into a fresh frame and read the route subs. Inside with-new-frame, rf/subscribe reads from that frame:

(deftest navigate-changes-the-route
  (rf/with-new-frame [f (rf/make-frame {})]
    (rf/dispatch-sync [:rf.route/navigate {:to :app/article :params {:slug "intro"}}])
    (is (= :app/article @(rf/subscribe [:rf.route/id])))
    (is (= {:slug "intro"} @(rf/subscribe [:rf.route/params])))))

A route's :on-match events run inside the same dispatch-sync, so assert on what they did — here, @(rf/subscribe [:article/current]). If one starts managed HTTP, stub it as any pipeline-run test would. :on-match never changes :rf.route/transition, so do not watch the transition to prove it ran.

:rf.route/transition and :rf.route/error report the route's blocking :resources. Test those by stubbing the resource read, as Testing resources describes: a blocking read still on its first load gives :loading, and a failed one puts a structured error on :rf.route/error. Assert on the error's category, not its message.

A pasted link, a reload, Back/Forward and a server-rendered request all arrive as :rf.route/handle-url-change with the URL. Dispatch it directly:

(deftest deep-link-opens-the-article
  (rf/with-new-frame [f (rf/make-frame {})]
    (rf/dispatch-sync [:rf.route/handle-url-change "/articles/intro"])
    (is (= :app/article @(rf/subscribe [:rf.route/id])))))

(deftest unknown-url-lands-on-not-found
  (rf/with-new-frame [f (rf/make-frame {})]
    (rf/dispatch-sync [:rf.route/handle-url-change "/no/such/page"])
    (is (= :rf.route/not-found @(rf/subscribe [:rf.route/id])))
    (is (= "/no/such/page" (:url @(rf/subscribe [:rf.route/params]))))))

Not-found params also carry a :reason: absent for a plain miss, :validation when the params failed their schema, :malformed-url for bad percent-encoding, and :match-error when matching the URL threw. Add a case for each one your not-found page treats differently.

Server rendering uses the same event on a server frame, (rf/make-frame {:platform :server}), so it needs no separate route tests; see Testing SSR.

The runtime records how a navigation arrived, its cause, and uses it to pick the default scroll: :top for a link click or a :rf.route/navigate, :restore for Back, Forward and the first load. The cause also appears in entry denials and blocked navigations. A bare dispatch on a client frame is recorded as :initial, the cause for a deep link or reload. To stand in for a link click or Back/Forward, pass the cause the framework would have attached:

;; a link click
(rf/dispatch-sync [:rf.route/handle-url-change "/articles/intro" {:rf.route/cause :link}])

;; Back/Forward
(rf/dispatch-sync [:rf.route/handle-url-change "/articles/intro" {:rf.route/cause :popstate}])

In a real app the framework adds these itself, so pass one only when simulating that entry point, and do not install a listener of your own to get it. Without it, a test named for a link click checks the :restore scroll default instead of :top.

Guards

The guard examples extend app.core with the editor events from Guard against unsaved changes and the sign-in handlers from Require sign-in on a route. Loading that namespace registers them before any test creates a frame.

A :can-leave block is state you can read, and the reader's answer is a dispatch. The frame below starts on the editor with unsaved changes, set up through :initial-events:

(deftest leaving-the-editor-asks-first
  (rf/with-new-frame [f (rf/make-frame
                          {:initial-events
                           [[:rf.route/navigate {:to :app/article-editor :params {:slug "intro"}}]
                            [:editor/edit "A new title"]]})]
    (rf/dispatch-sync [:rf.route/navigate {:to :app/home}])
    (is (some? @(rf/subscribe [:rf/pending-navigation])))
    (is (= :app/article-editor @(rf/subscribe [:rf.route/id])))

    (rf/dispatch-sync [:rf.route/continue
                       (:id @(rf/subscribe [:rf/pending-navigation]))])
    (is (nil? @(rf/subscribe [:rf/pending-navigation])))
    (is (= :app/home @(rf/subscribe [:rf.route/id])))))

Dispatching [:rf.route/cancel <id>] instead clears the pending navigation and leaves the route unchanged. Navigating with :bypass-leave? true skips the leave guard; the destination's entry guard can still deny it.

A :can-enter refusal parks nothing, so there is no pending value to check. Assert on what the :rf.route/entry-denied handler did instead: it stored the denied :destination and sent the reader to /login, and signing in brings them back:

(deftest settings-returns-the-reader-after-sign-in
  (rf/with-new-frame [f (rf/make-frame {})]
    (rf/dispatch-sync [:rf.route/navigate {:to :app/settings}])
    (is (= :app/login @(rf/subscribe [:rf.route/id])))
    (is (= {:to :app/settings} (:auth/return-to (rf/app-db-value f))))

    (rf/dispatch-sync [:auth/sign-in {:name "Ada"}])
    (is (= :app/settings @(rf/subscribe [:rf.route/id])))))

dispatch-sync also runs the events the handler dispatches, so the redirect to login has happened by the first assertion. The stored destination leaves out an empty :params and :query and a nil :fragment, so a refused /settings is {:to :app/settings}. Returning after sign-in is an ordinary new navigation, and with a user present the guard allows it.

Read what the handler stored rather than registering a spy under :rf.route/entry-denied: the app registers that id already, and make-frame refuses one id registered by two namespaces with :rf.error/image-duplicate-id.

A frame interceptor that guards navigations, as in Require sign-in on a route, is tested like any other interceptor.

Troubleshooting

Symptom Cause Fix
@(rf/subscribe [:rf.route/id]) is still the old route after a navigate The request was rejected (a bad request map raises :rf.error/navigate-bad-request; a URL that cannot be built — a missing path param, or with re-frame.schemas loaded a value the schema rejects — raises :rf.error/schema-validation-failure), or a :can-enter guard refused it Fix the request, or for a guarded route assert on the denial
make-frame throws :rf.error/image-duplicate-id The test registers an id the app already registers, such as :rf.route/entry-denied Assert on what the app's handler does, or register the test's handler under an id of its own
A spy registered in the test is never called It was registered inside with-new-frame, after the frame was made Register it before make-frame
A link-click or Back/Forward test sees the :restore scroll default The dispatch has no :rf.route/cause, so it counts as :initial Pass the cause, as in Simulating a link click or Back/Forward
Handlers registered in one test are visible in the next The reset fixture is missing Add ts/make-reset-runtime-fixture; it rolls back registrations made during each test
There is no browser URL to assert on Test frames are not :url-bound?, so nothing writes the address bar Assert on the route subs, or on route-url of the expected address

Advanced

A cold boot that restores the session

If the app fetches the signed-in user at boot instead of reading a cached one, the first URL is checked before the reply arrives, so a protected deep link is judged with no user. Require sign-in on a route shows the fix. To test it, use a :url-bound? frame whose :url-strategy :decode reports the protected URL, the app's real :initial-events, and a managed-HTTP stub that records the request and does not answer until the test says so. A stub that answers at once, or a test that navigates to a public page first, cannot see the problem. realworld-cold-boot-deep-link-race in implementation/adapters/reagent/test/re_frame/realworld_cljs_test.cljs is a worked example.