Guide

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

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

**Note** — `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.

```ruby
test "toggle flips done" do
  todo = todos(:write_docs)         # done: false
  component = Todos::Item.new(todo:)
  component.toggle
  assert todo.reload.done?
end
```

## 2. 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.

```ruby
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:))
end
```

The `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`.

```ruby
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")
end
```

## 4. 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.

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

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

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

`reply.<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 `window` survives the interaction).
- A change in one session appears in another subscribed session.

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

For cross-tab, open two sessions (two `Capybara::Session`s 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:

```shell
bun test spec/javascript     # or: bun run test
```

They 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 `reactive` **eagerly**.
- **Click reloads the whole page** — a bare `<button>` defaulted to `type=submit` in a form. `on(:click)` already sets `type=button`; ensure you spread it.
- **403 from the endpoint** — undeclared action, or `authorize!` denied. Declare the `action`; check the policy.
- **400 from the endpoint** — token tampered, or the `action` key collided. Use the `act` wire 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_from` and `broadcast_*_to`.
- **Action POST redirected to /sign_in** — an auth filter on a public component. `skip_before_action :authenticate` on the endpoint, or keep it state-backed.