Skip to content

Add Storex.Test for driving a store from a test - #15

Merged
drozdzynski merged 2 commits into
masterfrom
feat/test-helper
Sep 14, 2026
Merged

drozdzynski merged 2 commits into
masterfrom
feat/test-helper

Conversation

@drozdzynski

Copy link
Copy Markdown
Member

Exercising a store today means going through Storex.Supervisor.add_store/4 and
mutate_store/4, both @moduledoc false — a test written against them is
written against internals that are free to move. The alternative is the browser
suite, which needs real Chrome through Wallaby with chromedriver on PATH.

Storex.Test is the supported way:

defmodule MyApp.Store.CounterTest do
  use ExUnit.Case

  test "increasing counts up" do
    store = Storex.Test.start_store!(MyApp.Store.Counter)

    assert {:ok, result} = Storex.Test.commit(store, "increase")

    assert result.state == %{counter: 1}
    assert result.diff == [%{a: "u", p: [:counter], t: 1}]
  end
end

result.diff is the reason this exists. The diff is the contract with the
frontend — it is what actually goes over the wire — and it is the part a store
author cannot otherwise get at without driving a browser.

The API

Storex.Test.start_store(Counter, params: %{...})  # {:ok, handle} | {:error, reason}
Storex.Test.start_store!(Counter)
Storex.Test.state(handle)
Storex.Test.commit(handle, "set", [3])            # {:ok, %{state:, diff:, message:}}
Storex.Test.commit!(handle, "set", [3])
Storex.Test.broadcast(handle, "reload", [], key: "user_id")
Storex.Test.stop(handle)

commit/3 returns a plain map rather than a struct, so a test can match the part
it cares about and ignore the rest. {:error, reason} is whatever the client
would have received as an error frame: a mutation returning {:error, reason}, a
name no clause matches, or an unsupported return value. start_store/2 returns
{:error, reason} when the store's init/2 does, so refusing to start is
testable; the bang variants raise instead, for the cases a test does not mean to
assert on.

Storex.Test lives in lib/ so it ships to users, the way Plug.Test does.
mix.exs already packages lib.

broadcast/4 closes a real gap

Storex.mutate/3 fans out over :pg and sends the mutation to each session's
socket process, which is what normally turns it into a call on the store. There
is no socket in a unit test — this repo writes a 30-line FakeWebsocketServer in
storex_test.exs to work around exactly that. broadcast/4 waits for the
message and applies it, so the :pg path, the registry match and the :key
filter are covered by one call. {:error, :timeout} is the assertion for a store
the fan-out should not have reached.

stop/1 waits

Storex.Supervisor.remove_store/2 is a cast, so asserting on what terminate/3
did on the next line is a race. stop/1 monitors the store and waits for it to go
down.

Cleanup is not a convenience

start_store/2 registers an ExUnit on_exit that stops the store.
Storex.Registry monitors the store process, not the session, so without this
every test that starts a store leaks one for the rest of the run. ExUnit is
reached through apply/3 behind Code.ensure_loaded?/1, so the module carries no
compile-time reference to a test-only dependency and
mix compile --warnings-as-errors stays clean in dev and prod.

The calling process is registered as the session, which is what lets broadcast/4
observe the fan-out.

Deliberately not done

  • The existing suite was not migrated onto it. supervisor_test.exs and
    store_test.exs test Storex.Supervisor and Storex.Store on purpose, which
    is the layer below this. Rewriting them through the helper would lose that.
  • No SSR helper. Storex.HTTP.init_store/2 is already a plain function
    returning a plain map, and http_test.exs calls it directly without ceremony.
  • No assertion macros (assert_diff, assert_state). commit/3 hands back
    plain data and assert on it reads fine; a macro layer would be more to learn,
    not less.

Tests

test/storex/test_test.exs, 28 tests covering every function, both bang variants,
the error paths, the :key filter, terminate/3 through stop/1, and the
automatic cleanup. 143 non-browser tests pass on three seeds;
mix compile --warnings-as-errors and mix format --check-formatted are clean.
The 21 browser tests still need chromedriver and did not run here.

@drozdzynski
drozdzynski merged commit 2b24ca6 into master Sep 14, 2026
14 checks passed
@drozdzynski
drozdzynski deleted the feat/test-helper branch September 14, 2026 05:32
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant