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.
Setup
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:
# spec/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
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")
end
endWhat 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
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
.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
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
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"])
end
endThere is no Flight here — do not use have_ruact_component.
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"))
end
endSummary
| 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.