Skip to main content

guides/testing.md

# Testing Stores

`Musubi.Testing` is the public test entry point for stores authored on top
of Musubi. It wraps `Musubi.Page.Server.start_link/1` with test-friendly
defaults and exposes the primary assertion surface, modelled on
`Phoenix.LiveViewTest`.

## Doctrine: assert through `render/2`, not `assigns/2`

Test the rendered wire-shape map — what the client would observe — not
internal `socket.assigns`. Renaming or splitting a field then trips the
test; assertions coupled to internal storage do not.

`assigns/2` is documented as an escape hatch for state that is not
surfaced through `render/1`. Reach for it sparingly.

## Minimum example

Suppose a store tracks a 1v1 fight:

```elixir
defmodule MyApp.Stores.RoomStore do
  use Musubi.Store, root: true

  state do
    field :hp, %{p1: integer(), p2: integer()}
    field :winner, :p1 | :p2 | :draw | nil
  end

  command :ko do
    payload do
      field :target, String.t()
    end

    reply do
      field :ok, boolean()
    end
  end

  @impl Musubi.Store
  def mount(_params, socket) do
    socket =
      socket
      |> Musubi.Socket.assign(:hp, %{p1: 100, p2: 100})
      |> Musubi.Socket.assign(:winner, nil)

    {:ok, socket}
  end

  @impl Musubi.Store
  def render(socket) do
    %{hp: socket.assigns.hp, winner: socket.assigns.winner}
  end

  @impl Musubi.Store
  def handle_command(:ko, %{"target" => "p2"}, socket) do
    socket =
      socket
      |> Musubi.Socket.assign(:hp, %{p1: 100, p2: 0})
      |> Musubi.Socket.assign(:winner, :p1)

    {:reply, %{"ok" => true}, socket}
  end
end
```

A focused test:

```elixir
defmodule MyApp.Stores.RoomStoreTest do
  use ExUnit.Case, async: true

  test "ko on p2 flips winner to p1" do
    page = Musubi.Testing.mount(MyApp.Stores.RoomStore, %{"room_code" => "AB12"})

    {:ok, %{"ok" => true}} =
      Musubi.Testing.dispatch_command(page, :ko, %{"target" => "p2"})

    assert Musubi.Testing.render(page) == %{
             hp: %{p1: 100, p2: 0},
             winner: :p1
           }
  end
end
```

## Wire shape: atoms stay atoms in `render/2`

`render/2` returns native Elixir terms — `:p1` stays an atom, the
`%{p1: ...}` map keeps atom keys. The JSON-string conversion happens
downstream on the way to the client (see "Wire Encoding: Atoms Become
Strings" in `Getting Started`). Tests are easier to read this way.

If you need to assert against the actual wire shape, pipe through
`Musubi.Wire.to_wire/1`:

```elixir
wire = page |> Musubi.Testing.render() |> Musubi.Wire.to_wire()
assert wire == %{"hp" => %{"p1" => 100, "p2" => 0}, "winner" => "p1"}
```

## Addressing child stores

`render/2`, `dispatch_command/4`, and `assigns/2` all accept an optional
`store_id` — the path from the root to the addressed node, matching the
shape of `Musubi.Socket.store_id/1`. The default `[]` addresses the root.

| `store_id`           | Addresses                              |
| :------------------- | :------------------------------------- |
| `[]`                 | root                                   |
| `["filters"]`        | root → child mounted with `id: "filters"` |
| `["cart", "i-42"]`   | root → cart child → its child `"i-42"` |

Each segment matches the `:id` passed to `Musubi.Child.child(Module, id: "...")`
inside the parent's `render/1` output. Segments are strings.

### Worked example

Parent + child store:

```elixir
defmodule MyApp.Stores.FiltersStore do
  use Musubi.Store

  state do
    field :query, String.t()
  end

  command :change_query do
    payload do
      field :query, String.t()
    end

    reply do
      field :ok, boolean()
    end
  end

  @impl Musubi.Store
  def mount(socket), do: {:ok, Musubi.Socket.assign(socket, :query, "")}

  @impl Musubi.Store
  def render(socket), do: %{query: socket.assigns.query}

  @impl Musubi.Store
  def handle_command(:change_query, %{"query" => q}, socket) do
    {:reply, %{"ok" => true}, Musubi.Socket.assign(socket, :query, q)}
  end
end

defmodule MyApp.Stores.RootStore do
  use Musubi.Store, root: true

  state do
    field :filters, MyApp.Stores.FiltersStore.t()
  end

  @impl Musubi.Store
  def mount(_params, socket), do: {:ok, socket}

  @impl Musubi.Store
  def render(_socket) do
    %{filters: Musubi.Child.child(MyApp.Stores.FiltersStore, id: "filters")}
  end

  @impl Musubi.Store
  def handle_command(_name, _payload, socket), do: {:noreply, socket}
end
```

Test that dispatches to the child and asserts on its rendered output:

```elixir
test "filter query updates through the child store" do
  page = Musubi.Testing.mount(MyApp.Stores.RootStore)

  {:ok, %{"ok" => true}} =
    Musubi.Testing.dispatch_command(
      page,
      :change_query,
      %{"query" => "shirt"},
      ["filters"]
    )

  assert Musubi.Testing.render(page, ["filters"]) == %{query: "shirt"}
end
```

### Lifecycle notes

- The child is mounted lazily by the resolver during the first render
  cycle, triggered automatically by `Musubi.Testing.mount/3`. By the
  time `dispatch_command/4` runs, the child is in the store table.
- Calling `dispatch_command/4` with a `store_id` for a child that was
  never rendered raises — the lookup fails fast.
- `Musubi.Testing.render(page)` at the root returns the raw `render/1`
  output, including the `%Musubi.Child{...}` placeholder. The
  placeholder is substituted with the child's rendered output later in
  the wire pipeline before the patch envelope ships to the client. Use
  `render(page, ["filters"])` to assert on the child's own output, or
  pipe through `Musubi.Wire.to_wire/1` to see the resolved wire shape.

## Push patches: handled automatically

By default `mount/3` sets the test process as the transport pid, so
push-patch envelopes arrive in the test mailbox. Most tests do not need
to consume them — `render/2` runs the store's `render/1` against the
current socket and is sufficient for state assertions.

If a test needs to observe patch sequencing (e.g. verifying a stream
operation was emitted), assert on the mailbox:

```elixir
assert_receive {:patch, %Musubi.Page.PatchEnvelope{ops: ops}}
```

## Teardown

`mount/3` uses `ExUnit.Callbacks.start_supervised!/1`, so the page
server is torn down with the test process. No manual cleanup needed.