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:
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
endA focused test:
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
endWire 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:
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:
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}
endTest that dispatches to the child and asserts on its rendered output:
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"}
endLifecycle notes
- The child is mounted lazily by the resolver during the first render
cycle, triggered automatically by
Musubi.Testing.mount/3. By the timedispatch_command/4runs, the child is in the store table. - Calling
dispatch_command/4with astore_idfor a child that was never rendered raises — the lookup fails fast. Musubi.Testing.render(page)at the root returns the rawrender/1output, 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. Userender(page, ["filters"])to assert on the child's own output, or pipe throughMusubi.Wire.to_wire/1to 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:
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.