Testing
ruact renders on the server, so you can verify what a request produced without a browser — right in a Rails request or controller spec. This guide covers the three response shapes ruact emits and how to assert on each:
- Pages — a Flight render. Assert with the
have_ruact_componentmatcher. - Function calls — a server mutation. Plain JSON; assert with
JSON.parse. - Queries — a read endpoint. Plain JSON too.
The one rule to internalize: only page responses are Flight. Function-call and query responses are ordinary JSON — reach for JSON.parse(response.body), never the Flight matcher.
See Flight Wire Format for what a page response actually contains.
The render-assertion helpers are not loaded by require "ruact" (a production boot must not depend on RSpec). Load them explicitly from your spec_helper.rb or rails_helper.rb:
require "ruact/testing"That registers the have_ruact_component matcher for every example group. It is a stable public API — a conventional test matcher, safe to build your suite on.
1. Pages — have_ruact_component
Section titled “1. Pages — have_ruact_component”A page action renders ERB (your server component). Assert that the response rendered a given component, optionally with given props:
require "rails_helper"
RSpec.describe "Posts index", type: :request do it "renders the PostList with the posts" do get "/posts"
expect(response).to have_ruact_component("PostList") expect(response).to have_ruact_component("PostList") .with_props(a_hash_including("posts")) end
it "does not render the admin panel for a guest" do get "/posts"
expect(response).not_to have_ruact_component("AdminBar") endendWhat the matcher accepts
Section titled “What the matcher accepts”have_ruact_component reads the response’s .body (an ActionDispatch::TestResponse, a rack-test last_response, or a plain String all work), and extracts the Flight payload from either page shape:
- a raw
text/x-componentbody (an RSC / navigation request — e.g. one carryingAccept: text/x-component), or - the full HTML document a plain browser
GETreceives, where the payload is inlined in the__FLIGHT_DATAbootstrap script.
You do not need to know which one your request produced — pass the response and the matcher figures it out.
How the component name resolves
Section titled “How the component name resolves”A component appears in the wire as an import row (its module path + export name, from the client manifest) plus a model row carrying its props. have_ruact_component("PostList") matches the name the wire actually carries: the import’s export name or its module basename (/components/PostList.jsx matches "PostList"). It does not invent a mapping the wire does not contain.
Props are asserted in serialized wire form
Section titled “Props are asserted in serialized wire form”.with_props(...) matches against the serialized props hash — string keys, values already reduced to their wire form (records serialized to hashes, dates as ISO8601 strings, and so on) — not the Ruby objects you passed in the ERB. Use RSpec’s composable matchers for anything but an exact match:
expect(response).to have_ruact_component("PostCard") .with_props(a_hash_including("id" => 42, "title" => a_string_matching(/hello/)))A bare have_ruact_component("PostCard") asserts presence with any props. Both forms support not_to, and a failing assertion prints what was found versus what was expected.
If you hand it a JSON response
Section titled “If you hand it a JSON response”Passing a function-call or query response (plain JSON) to have_ruact_component raises a clear Ruact::Testing::NotAFlightResponseError telling you to use JSON.parse(response.body) instead — it never silently fails to parse.
2. Function calls — JSON.parse
Section titled “2. Function calls — JSON.parse”A server mutation is a normal non-GET controller action on a controller that does include Ruact::Server. When the client calls it (Accept: application/json), it answers plain JSON — the exposed instance variables — or 204 No Content, or a { "$redirect": "/path" } instruction. Assert on it as ordinary JSON:
RSpec.describe "Create post", type: :request do it "creates the post and returns it as JSON" do post "/posts", params: { post: { title: "Hello" } }.to_json, headers: { "Content-Type" => "application/json", "Accept" => "application/json" }
expect(response).to have_http_status(:created)
body = JSON.parse(response.body) expect(body["post"]).to include("title" => "Hello") end
it "returns validation errors on an invalid post" do post "/posts", params: { post: { title: "" } }.to_json, headers: { "Content-Type" => "application/json", "Accept" => "application/json" }
body = JSON.parse(response.body) expect(body["errors"]).to include("title" => ["can't be blank"]) endendThere is no Flight here — do not use have_ruact_component.
3. Queries — JSON.parse
Section titled “3. Queries — JSON.parse”A server query is a public method on a Ruact::Query class, mounted with ruact_queries at a named GET /q/<jsId> route. Queries answer JSON as well, so they are asserted the same way:
RSpec.describe "Catalog query", type: :request do it "returns the matching products as JSON" do get "/q/searchProducts", params: { term: "shoe" }
expect(response).to have_http_status(:ok)
body = JSON.parse(response.body) expect(body).to all(include("name")) endendSummary
Section titled “Summary”| Response shape | How it is emitted | How to assert |
|---|---|---|
| Page | Flight (text/x-component, or HTML shell with __FLIGHT_DATA) |
have_ruact_component("Name").with_props(...) |
| Function call | include Ruact::Server action, plain JSON / 204 / $redirect |
JSON.parse(response.body) + status |
| Query | Ruact::Query method at GET /q/<jsId>, plain JSON |
JSON.parse(response.body) + status |
For the byte-level details of a page response, see Flight Wire Format.