Testing reactive components
Reactive components are plain Ruby objects with a few extra class methods, so most testing is fast unit testing — three layers, cheapest first.
Setup: the public test helpers#
Mix in Phlex::Reactive::TestHelpers once. It ships token minting (reactive_token_for), the no-HTTP action driver (run_reactive), request helpers (post_reactive_action/post_reactive_multipart), and the have_reactive_* matchers — so you never reach for a private method or hand-roll a POST.
# spec/rails_helper.rb (RSpec)
RSpec.configure do |c|
c.include Phlex::Reactive::TestHelpers # driver + matchers everywhere
c.include Phlex::Reactive::TestHelpers, type: :request # + the HTTP helpers
endNote — verbose_errors defaults ON in test (it only changes an error BODY, never a status). If you assert an empty failure body, set Phlex::Reactive.verbose_errors = false in your setup.
1. Unit: actions are just methods#
Build the component, call the action, assert the state changed. No HTTP, no browser.
test "toggle flips done" do
todo = todos(:write_docs) # done: false
component = Todos::Item.new(todo:)
component.toggle
assert todo.reload.done?
end2. Unit: run_reactive drives the action through the security contract#
Calling the method directly (above) skips what the real endpoint enforces. run_reactive runs the action through the SAME contract — default-deny, the signed identity round-trip (the record is re-found), schema coercion — with no HTTP, and returns a Result you assert on. So a unit test can't pass on a component that would fail a real click.
test "set coerces the :integer param and replaces the component" do
# The client sends strings; the schema declares count: :integer.
result = run_reactive(Counter.new(count: 0), :set, count: "42")
expect(result).to have_reactive_replace("counter")
expect(result.component.instance_variable_get(:@count)).to eq(42) # cast, not "42"
end
test "an undeclared action is denied (default-deny)" do
expect { run_reactive(Counter.new(count: 0), :drop_table) }
.to raise_error(Phlex::Reactive::TestHelpers::UndeclaredReactiveAction)
end
test "a deleted record surfaces as RecordNotFound (the endpoint's 404)" do
item = Todos::Item.new(todo:)
todo.destroy!
expect { run_reactive(item, :toggle) }.to raise_error(ActiveRecord::RecordNotFound)
end
test "archive removes the row (and carries no replace)" do
result = run_reactive(Todos::Item.new(todo:), :archive)
expect(result).to have_reactive_remove(Todos::Item.new(todo:))
endThe Result exposes replace?/remove?/redirect?/redirect_url/streams/response, plus component — the instance REBUILT from identity, the one the action actually ran against. A registered authorization error RAISES (the endpoint maps it to 403); assert with raise_error.
3. Unit: the identity token round-trips#
Verify the signed token rebuilds the same component (and that tampering fails). reactive_token_for mints the token the component would — no private send.
test "record-backed identity round-trips" do
todo = todos(:write_docs)
token = reactive_token_for(Todos::Item.new(todo:))
payload = Phlex::Reactive.verify(token)
assert_equal "Todos::Item", payload["c"]
rebuilt = Todos::Item.from_identity(payload)
assert_equal todo, rebuilt.instance_variable_get(:@todo)
end
test "tampered token is rejected" do
token = reactive_token_for(Counter.new(count: 1))
assert_nil Phlex::Reactive.verify(token + "x")
end4. Integration: the action endpoint#
When you want the FULL HTTP round trip (routing, CSRF, the real status codes), post_reactive_action posts a signed token to Phlex::Reactive.action_path the way the client does — pass a component instance (or a class + payload:) and assert the turbo-stream response.
test "increment action returns a turbo-stream replace" do
post_reactive_action(Counter.new(count: 1), :increment)
assert_response :success
assert_match %r{<turbo-stream action="replace" target="counter">}, response.body
assert_match %r{>2<}, response.body # count incremented 1 -> 2
end
test "undeclared action is forbidden" do
post_reactive_action(Counter.new(count: 1), :drop_table)
assert_response :forbidden
endFor authorization, stub the current user / policy and assert :forbidden when the action's authorize! should deny.
When an action returns a reply.<verb>, assert on the streams it produces:
test "failed update keeps the replace and appends a flash" do
# POST a save action with invalid input
assert_response :success
assert_match %r{<turbo-stream action="replace" target="counter">}, response.body # token refreshes
assert_match %r{<turbo-stream action="append" target="flash">}, response.body # flash appended
end
test "remove action emits a remove and no replace" do
assert_response :success
assert_match %r{<turbo-stream action="remove" target="todo_42">}, response.body
refute_match %r{action="replace"}, response.body # render_self? is false for remove
end
test "redirect rides a 200 reactive:visit, not a 3xx" do
assert_response :success # NOT :redirect — the client bails on response.redirected
assert_match %r{<turbo-stream action="reactive:visit" data-url=".*/articles/}, response.body
endreply.<verb> is a plain value object — unit-test it with no HTTP: c.reply.remove.render_self? is false; c.reply.replace.flash(:x, "hi").streams.size is 2.
5. System / browser: the full loop & broadcasts#
Use a system test (Capybara) or a browser-automation CLI for the end-to-end loop and cross-tab broadcasts. The key assertions:
- Clicking a trigger updates the component without a full page reload (assert a value set on
windowsurvives the interaction). - A change in one session appears in another subscribed session.
# system test sketch
test "counter increments without reload" do
visit counter_path
page.execute_script("window.__marker = 'alive'")
click_button "+"
assert_selector "#counter .value", text: "1"
assert_equal "alive", page.evaluate_script("window.__marker") # no reload
endFor cross-tab, open two sessions (two Capybara::Sessions or two browser contexts), act in one, and assert the morph appears in the other. Allow a beat for the SSE round trip.
The transport matrix. The gem is transport-agnostic — a reactive round trip must work on Action Cable AND pgbus. Its own CI proves this by running the browser suite across server × transport (Puma/Falcon × cable/pgbus). The cable cells run on SQLite; the pgbus cells add a plain postgres:18 (pgbus vendors the PGMQ schema via its own migrations — no extension image) and set TRANSPORT=pgbus, so the same specs run over real Postgres-backed SSE. A handful of :pgbus-tagged specs additionally prove real cross-tab delivery and actor-echo exclusion (the exclude: reactive_connection_id contract) end to end.
Falcon needs fiber isolation. Falcon runs each request as a fiber on one thread, and Rails keys its per-request state — and the lock on the single connection transactional tests pin for the test and the server to share — on config.active_support.isolation_level. Under the default :thread two request fibers look like one owner, both enter that lock, and the first page that makes two requests overlap wedges the server (a ThreadError in the connection pool, then every later request blocks). Set it to :fiber for an app served by Falcon — the dummy app does so when CAPYBARA_SERVER=falcon; the Puma cells keep :thread, so both configurations stay proven.
# both servers, Action Cable (the default local sweep)
bundle exec rake spec:system_servers
# the full matrix: puma/falcon × cable/pgbus (needs local Postgres;
# the pgbus cells skip with a note when Postgres isn't reachable)
bundle exec rake spec:system_matrix
# one pgbus cell by hand
bundle exec rake pgbus:prepare_test_db # once, sets up the DB
TRANSPORT=pgbus CAPYBARA_SERVER=falcon bundle exec rspec spec/system6. System helpers: wait on the reactive layer, re-resolve by id#
A reactive morph, a reactive_compute re-seed, or an in-flight round trip can replace DOM nodes after the triggering action returns — so a Capybara node held by find detaches (StaleReferenceError) and have_field(with:) can read a transient blank before the value settles. wait_for_turbo does not help — it watches the Turbo progress bar, not a reactive morph.
The client exposes a global activity signal for exactly this: a data-reactive-active marker on <html> (present while any dispatch or deferred render is in flight) plus reactive:busy / reactive:idle events on document. Phlex::Reactive::TestHelpers::System — loaded only when Capybara is present — waits on it:
wait_for_reactive— block until the whole layer is idle (every round trip AND deferred render settled). The system twin ofwait_for_turbo.have_reactive_value(id, value)— assert a field by RE-RESOLVING it every poll (by id or name) and reading its live.valueproperty, not the value attribute. Areactive_computereducer paints a computed output withel.value = …; for a disabled output the attribute never reflects that, so an attribute matcher reads blank — the property read sees it. Immune to a morph detaching the node.have_reactive_text(id, value)— the mirror/recap twin for a text sink (textContent), re-resolving by id each poll.have_reactive_requests(n, kind: nil)— assert exactlynreactive requests (:action,:defer, or any kind) since the lastreset_reactive_requests!, waiting until nothing is in flight.reactive_request_countreturns the totals ({ action: 1, defer: 0 }). The client keeps them on<html data-reactive-requests>only under the verbose gate (on in dev/test), so production writes nothing. No fetch spy: requests made before the spec looks are counted, and unrelated fetches are not. The matcher waits for requests in flight, not for ones that have not started: after a debounced trigger, or an action whose reply starts a deferred render, wait for the UI outcome first. A lazy shell fetches on connect, so callwait_for_reactivebefore a reset that should exclude it. A deferred render pushed over a pgbus stream makes no request and is not counted.
# rails_helper.rb — mix into system examples (Capybara-gated)
RSpec.configure do |c|
c.include Phlex::Reactive::TestHelpers::System, type: :system
end
# spec/system/…_spec.rb
it "settles the deferred total, no stale read" do
visit "/order"
find("[data-testid='bump']").click
wait_for_reactive # round trip + defer settled
expect(page).to have_reactive_value("total", "6") # re-resolves by id
expect(page).to have_reactive_text("recap", "6 items")
end
it "loads the panel once" do
visit "/"
reset_reactive_requests! # baseline (also after a Turbo visit)
find("#open-panel").click
expect(page).to have_reactive_requests(1) # waits, then exact count
find("#close-panel").click
find("#open-panel").click
expect(page).to have_reactive_requests(1) # still one: reused
expect(page).to have_reactive_requests(0, kind: :defer)
end
# A reactive_lazy(cache:) fragment GET counts as kind: :defer per fetch()
# call — also when the browser cache answers it. "No network request":
it "reuses the cached fragment on the next page view" do
2.times do
visit "/"
expect(page).to have_css("#account-menu a")
end
transfers = page.evaluate_script(<<~JS)
performance.getEntriesByType("resource")
.filter((e) => e.name.includes("/reactive/fragment/")).map((e) => e.transferSize)
JS
expect(transfers).to eq([0]) # this page's fetch never left the browser
end7. Client unit tests (bun)#
Some client-runtime contracts are timing-sensitive and a full browser can mask them — e.g. a submit trigger must preventDefault() synchronously during the event, which a system test in a minimal app can't reliably reproduce. Those are covered by fast bun unit tests against the controller in isolation:
bun test spec/javascript # or: bun run testThey stub @hotwired/stimulus and assert behavior directly (e.g. dispatch() calls preventDefault() before its queued work). CI runs them in the System job, which already has bun set up. The system suite drives a vendored copy of the controller (spec/dummy/public/vendor/reactive_controller.js); a guard spec keeps it byte-identical to source, so edit the source and re-sync, never the copy.
Troubleshooting#
- Click does nothing, count stays 0 — the controller lazy-loaded and you clicked before connect. Register
reactiveeagerly. - Click reloads the whole page — a bare
<button>defaulted totype=submitin a form.on(:click)already setstype=button; ensure you spread it. - 403 from the endpoint — undeclared action, or
authorize!denied. Declare theaction; check the policy. - 400 from the endpoint — token tampered, or the
actionkey collided. Use theactwire key (the gem does); don't rename it. - Rapid clicks lose updates — (shouldn't happen) a stale-token race. The runtime queues + threads tokens; file a bug with a repro.
- Cross-tab not syncing — subscriber/broadcaster stream keys differ. Pass the same raw *streamables to
turbo_stream_fromandbroadcast_to. - Action POST redirected to /sign_in — an auth filter on a public component.
skip_before_action :authenticateon the endpoint, or keep it state-backed.