Skip to content

Testing SSR

Server tests are ordinary JVM tests. Your event handlers, subscriptions and views run on the JVM against a frame, which your handler, subscription and pipeline-run tests already exercise. This page covers the part SSR adds: the rendered string, the Ring response, the payload, hydration, and the error projector.

The tests below exercise the tutorial's app: app.core as it stands at the end of the tutorial, with :articles/seed, the :articles/slice subscription, the :app/root view and an :app/server-init that seeds sample-articles up to the request's limit and sets a Cache-Control header.

Render a view to a string

render-to-string is a pure function from hiccup to an HTML string. Make a frame, seed it through :initial-events, call the root view inside the frame's scope, and assert on the markup:

(ns app.ssr-test
  (:require [clojure.edn :as edn]
            [clojure.string :as str]
            [clojure.test :refer [deftest is use-fixtures]]
            [re-frame.core :as rf]
            [re-frame.ssr :as ssr]
            [re-frame.ssr.ring :as ssr.ring]
            [re-frame.test-support :as ts]
            [app.core]))

(use-fixtures :each
  (ts/make-reset-runtime-fixture {:adapter       ssr/adapter   ;; the server-side substrate
                                  :ambient-frame nil}))        ;; each test makes its own frame

(deftest the-root-view-renders-the-articles
  (rf/with-new-frame [f (rf/make-frame
                          {:initial-events [[:articles/seed [{:id "1" :title "Hello, server"}]]]})]
    (let [html (ssr/render-to-string ((rf/view :app/root)) {})]   ;; the view runs under f
      (is (str/includes? html "Hello, server")))))

ssr-handler makes each per-request frame from its :frame config, whose :initial-events boot it, so the test frame boots the way a request's frame does.

When the assertion is about structure rather than the serialised string, walk the hiccup as in Test a view. Use render-to-string when the string itself matters: markup a crawler reads, attribute serialisation, the head.

Call the Ring handler

ssr-handler returns a plain Ring handler, so an end-to-end server test builds one with the tutorial's app.server options and calls it with a request map, with no Jetty and no port:

(defn boot-events [request]                        ;; app.server's :initial-events fn
  [[:app/server-init {:limit (app.core/request-limit request)}]])

(def handler
  (ssr.ring/ssr-handler {:frame     {:initial-events boot-events}
                         :root-view (fn [] ((rf/view :app/root)))
                         :payload   [:articles]}))

(def request {:request-method :get :uri "/" :query-params {"limit" "1"}})

(deftest the-server-answers
  (let [response (handler request)]
    (is (= 200 (:status response)))
    (is (= "public, max-age=60" (get-in response [:headers "Cache-Control"])))
    (is (str/includes? (:body response) "Hello, server"))
    (is (not (str/includes? (:body response) "Hydration, verified")))))   ;; limit 1

The request map reaches boot-events as given. In production wrap-params supplies :query-params; here the test writes it directly.

Read the payload

The body carries the payload as EDN inside <script id="__rf_payload" type="application/edn">. Read it back and the allowlist becomes an assertion on data:

(defn read-payload [body]
  (some->> body
           (re-find #"<script id=\"__rf_payload\"[^>]*>(.*?)</script>")
           second
           edn/read-string))

(deftest only-the-allowlist-ships
  (let [payload (read-payload (:body (handler request)))]
    (is (= #{:articles} (set (keys (:rf/app-db payload)))))))  ;; :articles/limit stays on the server

The payload escaper rewrites < inside strings as \u003c, which the EDN reader decodes, so the round trip is exact.

Redirects and cookies

The :rf.server/* effects become an ordinary Ring response. A redirect answers with its status, a Location header and an empty body. Each cookie becomes one Set-Cookie header: a string for one cookie, a vector of strings for several.

(deftest a-moved-page-redirects
  (rf/reg-event :test/moved
    {:platforms #{:server}}
    (fn [_ _]
      {:fx [[:rf.server/set-cookie {:name "flash" :value "moved" :path "/"}]
            [:rf.server/redirect   {:location "/articles" :status 301}]]}))
  (let [response ((ssr.ring/ssr-handler {:frame     {:initial-events [[:test/moved]]}
                                         :root-view (fn [] ((rf/view :app/root)))
                                         :payload   [:articles]})
                  {:request-method :get :uri "/old-articles"})]
    (is (= 301 (:status response)))
    (is (= "/articles" (get-in response [:headers "Location"])))
    (is (str/starts-with? (get-in response [:headers "Set-Cookie"]) "flash=moved"))
    (is (= "" (:body response)))))

The reset fixture rolls back the registration made inside the test. Control the response lists every effect and its arguments.

Keep the network out

:fx-overrides in the handler's :frame reaches every per-request frame, so it stubs an effect for the whole server render. Redirect :rf.http/managed to a function and the test sees each request the render fires, without sending it:

(deftest the-server-render-sends-no-requests
  (let [sent    (atom [])
        handler (ssr.ring/ssr-handler
                  {:frame     {:initial-events boot-events
                               :fx-overrides   {:rf.http/managed
                                                (fn [_frame-ctx args] (swap! sent conj args))}}
                   :root-view (fn [] ((rf/view :app/root)))
                   :payload   [:articles]})]
    (handler request)
    (is (empty? @sent))))

An override value is a function (fn [frame-ctx args] …) or another registered fx id, as in Redirect any effect. The handler does not wait for a fetch started from :initial-events; data the first render needs belongs in a route resource declared :blocking? true, which it does wait for.

Check hydration

Neither check below is complete hydration verification. The browser check sees the recoveries React makes on a real page; the JVM check sees whether the payload rebuilds the server's page. An attribute-only divergence escapes both (What is not reported), so read React's development console warning as well.

In the browser: watch React's recoveries

In a development or CI browser run, pass the root an :on-recoverable-error that collects what React reports while it adopts the server's markup:

(defonce hydration-errors (atom []))

(let [payload (ssr/hydrate! {:frame :rf/default})]
  (reagent-adapter/render!
    app-root
    [rf/frame-provider {:frame :rf/default} [(rf/view :app/root)]]
    (js/document.getElementById "app")
    {:hydrate?             (some? payload)
     :on-recoverable-error (fn [e _] (swap! hydration-errors conj e))}))

Once the hydration commit has landed, assert (empty? @hydration-errors): a browser test reads the atom, or the CI page fails its run on the first entry. The root still emits its :rf.ssr/hydration-mismatch report before calling your function, so the trace and the always-on record are unchanged. A Fresco root takes the same two keys on h/render!.

The callback alone cannot prove the attributes agree, because React calls it only for divergences it recovers from. In development, also watch console.error for React's attribute warning, whose message says the attributes "didn't match":

(defonce attribute-warnings (atom []))

(let [console-error js/console.error]
  (set! js/console.error
        (fn [& args]
          (when (str/includes? (str (first args)) "didn't match")
            (swap! attribute-warnings conj (first args)))
          (.apply console-error js/console (to-array args)))))

Production builds of React do not log the warning, so this part of the check needs a development build.

On the JVM: replay the payload

hydrate! also runs on the JVM when you pass the payload explicitly. Feed it the payload the handler shipped, render the root view against the hydrated frame, and compare the markup with what the server put inside <div id="app">. If the payload is not enough to reproduce the server's render (the root reads a key the allowlist left out, or reads the clock), the two strings differ and the test fails.

(defn app-element-html [html]                     ;; the markup inside <div id="app">
  (second (re-find #"(?s)<div id=\"app\">(.*)</div><script id=\"__rf_payload\"" html)))

(deftest the-payload-rebuilds-the-page
  (let [body    (:body (handler request))
        payload (read-payload body)]
    (rf/make-frame {:id :app :platform :client})
    (is (= payload (ssr/hydrate! {:frame :app :payload payload})))
    (is (= (app-element-html body)
           (rf/with-frame :app (ssr/render-to-string [(rf/view :app/root)]))))))

This checks payload reconstruction and deterministic server output: both renders run on the JVM, with the JVM's emitter, so it cannot establish that the browser renders the same markup. That is the browser check's job.

Test the boot guards and the error projector

A missing :payload policy throws when the handler is constructed, not on the first request. Thrown framework errors carry their id in ex-data, so assert on the id rather than the message:

(deftest payload-policy-is-mandatory
  (is (= :rf.error/ssr-missing-payload-policy
         (try (ssr.ring/ssr-handler {:frame     {:initial-events [[:app/server-init]]} ;; every required opt but :payload
                                     :root-view (fn [] ((rf/view :app/root)))})
              (catch Exception e (:rf.error/id (ex-data e)))))))

The error projector and the head function are pure functions. rf/reg-error-projector and rf/reg-head return the id they registered, not the function, so give the function a name and test that. A projector test passes a trace-event map and asserts the public shape, or nil for an error it leaves to the default projector; a head test passes (db, route) and asserts the head model (:title, the og: meta rows). Neither needs a frame:

(defn public-error [trace-event]
  (when (and (= :rf.error/no-such-handler (:operation trace-event))
             (= :route (get-in trace-event [:tags :kind])))
    {:status 404 :code :not-found :message "We couldn't find that page." :retryable? false}))

(rf/reg-error-projector :app/public-error public-error)

(deftest a-route-miss-is-a-404
  (is (= 404 (:status (public-error {:operation :rf.error/no-such-handler
                                     :op-type   :error
                                     :tags      {:kind :route}}))))
  (is (nil? (public-error {:operation :rf.error/no-such-handler   ;; an unregistered event:
                           :op-type   :error                     ;; the default projector
                           :tags      {:kind :event}}))))        ;; answers it with a 500

Platform gating needs no test of its own

A JVM test drain runs as a server-side drain. When a handler under test returns a #{:client} effect, such as a localStorage write, the resolver skips it and emits a :rf.fx/skipped-on-platform trace, as it will in a server render. So your existing pipeline-run tests already show your handlers are safe to run on the server: a handler that would crash a server render crashes the JVM test first.

What stays in the browser

The JVM replay renders both sides with the JVM's emitter, so it cannot see anything that differs between the JVM and the browser. Keep a browser run, with the browser check, for:

  • Adopting the DOM: the adapter's render! with {:hydrate? true} reconciling against the server markup, and React reporting what it recovers from.
  • Cross-runtime drift: a view whose #?(:clj …) and #?(:cljs …) branches render differently, or host-dependent output such as number and date formatting.

The tutorial's Step 5 trips a hydration mismatch on purpose.

Troubleshooting

Symptom Cause Fix
The JVM replay passes but the browser reports a mismatch The JVM and the browser render the view differently; the replay compares two JVM renders Find the #?(:clj …) / #?(:cljs …) or host-formatting difference, and keep the browser check in CI
The rendered body lacks data that a fetch loads The fetch was started from :initial-events, and the handler does not wait for it Declare the data as a route resource with :blocking? true, or stub the fetch with :fx-overrides
Constructing the handler throws :rf.error/ssr-missing-payload-policy No :payload option Name the app-db keys that may ship, or :rf.ssr.payload/whole-app-db

Advanced

The Node renderer

If you render on Node, the renderer is one handler option holding a function, so most of it tests on the JVM without a second process.

  • Renderer selection. :renderer takes any (fn [{:keys [frame-id request opts]}] → {:body-html …}). Pass a stub that returns a fixed string, and assert that the JVM's half of the page (the <head>, __rf_payload, the shell, the status and the cookies) is assembled around it correctly.
  • Construction validation. re-frame.ssr.ring.node/renderer validates its options at construction, so a malformed endpoint, entry, build id or timeout throws :rf.error/ssr-node-renderer-opt-invalid at boot. Assert on the id as in payload-policy-is-mandatory.

The JVM-to-Node call itself is covered by re-frame2's own test suite, which runs the real sidecar against a fixture render module through the success, refusal and deadline paths. Your suite is better spent on the stub.