Skip to content

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:

  1. Pages — a Flight render. Assert with the have_ruact_component matcher.
  2. Function calls — a server mutation. Plain JSON; assert with JSON.parse.
  3. 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:

ruby
# 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:

ruby
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
end

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-component body (an RSC / navigation request — e.g. one carrying Accept: text/x-component), or
  • the full HTML document a plain browser GET receives, where the payload is inlined in the __FLIGHT_DATA bootstrap 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:

ruby
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:

ruby
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
end

There 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:

ruby
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
end

Summary

Response shapeHow it is emittedHow to assert
PageFlight (text/x-component, or HTML shell with __FLIGHT_DATA)have_ruact_component("Name").with_props(...)
Function callinclude Ruact::Server action, plain JSON / 204 / $redirectJSON.parse(response.body) + status
QueryRuact::Query method at GET /q/<jsId>, plain JSONJSON.parse(response.body) + status

For the byte-level details of a page response, see Flight Wire Format.

Released under the MIT License.