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.
6. 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.