Inline execution and assertions for tests.
No engine, no background processes: drain/1 synchronously runs everything
runnable in the calling process, through the production pick/executor
path — so a test inserts, drains, delivers a signal, drains again, and
asserts. Because every query runs in the test process, it composes with
Ecto.Adapters.SQL.Sandbox ownership out of the box.
defmodule MyApp.CheckoutTest do
use ExUnit.Case, async: true
use GenDurable.Testing, repo: MyApp.Repo
test "checkout waits for payment, then ships" do
{:ok, id} = GenDurable.insert(Checkout, state: %{order: 42}, repo: MyApp.Repo)
assert %{await: 1} = drain()
assert_awaiting(id, "payment_confirmed")
:ok = GenDurable.signal(id, "payment_confirmed", %{amount: 100}, repo: MyApp.Repo)
assert %{done: 1} = drain()
assert_done(id, %{"shipped" => true})
end
enduse GenDurable.Testing, repo: MyRepo injects repo-bound wrappers for every
helper below; without use, call them with the repo as the first argument.
FSMs with a custom :name (or pinned old versions) need registering, exactly
as with the engine's :fsms option: use GenDurable.Testing, repo: MyRepo, fsms: [My.CustomNamed] (or per call, drain(fsms: [...])).
Inline semantics vs the engine:
- steps run one at a time, in pick order —
concurrency_keyserialization is trivially satisfied; - scheduled/backoff delays are collapsed by default (
with_scheduled: true) so retries andschedule_inrun immediately — an FSM that retries forever hits themax_stepscap and raises instead of hanging the test; - a step that raises routes to
handle/2exactly as in production; a bareexitcrashes the test process (there is no reaper to catch it); - await timeouts do not fire by themselves (that is the reaper's tick) —
fire_timeouts/1force-fires every armed deadline.
Summary
Functions
Assert the instance is parked on a signal await — optionally on the given
name(s) (a subset check against its awaits). Returns the row map.
Assert the instance is :done — and, unless :any, that its result equals result. Returns the result.
Assert the instance is :failed — and, unless :any, that last_error matches (== for strings, =~ for regexes). Returns last_error.
Assert the instance exists and has expected status. Returns the row map.
Synchronously execute runnable instances until quiescence (nothing left that can run) and return a tally of the outcomes committed
Fetch an instance for arbitrary asserts: a map with :id, :fsm, :step,
:status (atom), :state, :result, :last_error, :attempt, :awaits,
:queue — or nil. target is the internal id, or a correlation key (the
occupied instance; falls back to the latest row carrying the key, so a
finished instance is still assertable by key).
Force-fire every armed await deadline: parked instances with a timeout: go
straight back to runnable (as if the deadline had passed and the reaper
swept), regardless of how much time is actually left. Follow with drain/1.
Functions
Assert the instance is parked on a signal await — optionally on the given
name(s) (a subset check against its awaits). Returns the row map.
Assert the instance is :done — and, unless :any, that its result equals result. Returns the result.
Assert the instance is :failed — and, unless :any, that last_error matches (== for strings, =~ for regexes). Returns last_error.
Assert the instance exists and has expected status. Returns the row map.
Synchronously execute runnable instances until quiescence (nothing left that can run) and return a tally of the outcomes committed:
%{steps: 5, next: 3, retry: 0, await: 1, schedule_childs: 0, done: 1, stop: 0}Options:
:repo— required (injected byuse);:queue— drain one queue only (default: all queues);:with_scheduled— collapse futureeligible_at(scheduled work, retry backoffs) so it runs immediately (defaulttrue);:fsms— modules to register explicitly, as the engine's:fsmsoption (only for custom:names/versions — module-named FSMs resolve dynamically);:max_steps— safety cap, raises when exceeded (default1_000): an FSM that retries or transitions forever fails the test instead of hanging it.
Fetch an instance for arbitrary asserts: a map with :id, :fsm, :step,
:status (atom), :state, :result, :last_error, :attempt, :awaits,
:queue — or nil. target is the internal id, or a correlation key (the
occupied instance; falls back to the latest row carrying the key, so a
finished instance is still assertable by key).
Test-only introspection: the public API deliberately does not expose
state/result (they are GC-bounded); in a test you own the lifecycle.
Force-fire every armed await deadline: parked instances with a timeout: go
straight back to runnable (as if the deadline had passed and the reaper
swept), regardless of how much time is actually left. Follow with drain/1.