Packages
double_down
0.69.0
0.69.0
0.68.0
0.66.0
0.65.0
0.64.1
0.64.0
0.63.3
0.63.2
0.63.1
0.63.0
0.62.1
0.61.0
0.60.4
0.60.3
0.60.2
0.60.1
0.60.0
0.59.0
0.58.0
0.57.0
0.56.1
0.56.0
0.55.0
0.54.0
0.53.0
0.52.3
0.52.2
0.52.1
0.52.0
0.51.0
0.50.1
0.50.0
0.49.0
0.48.1
0.48.0
0.47.2
0.47.1
0.47.0
0.46.3
0.46.2
0.46.1
0.46.0
0.45.0
0.44.0
0.43.0
0.42.0
0.41.1
0.41.0
0.40.0
0.39.0
0.38.0
0.37.2
0.37.0
0.35.0
0.34.0
0.33.0
0.32.0
0.31.1
0.31.0
0.30.1
0.30.0
0.29.0
0.28.1
0.28.0
0.27.0
0.26.0
0.24.0
Builds on the Mox pattern — generates behaviours and dispatch facades from `defcallback` declarations — and adds stateful test doubles powerful enough to test Ecto.Repo operations without a database.
Current section
Files
Jump to
Current section
Files
docs/stateful-doubles.md
# Stateful Doubles
<!-- nav:header:start -->
[< Dispatch](dispatch.md) | [Up: Guides](../README.md) | [Index](../README.md) | [Double API >](double-api.md)
<!-- nav:header:end -->
Stateless doubles (stubs, module mocks) return canned responses — they
have no memory. Stateful doubles maintain in-memory state with atomic
updates, enabling read-after-write consistency and realistic behaviour
without a database.
## How state works
State lives inside `NimbleOwnership`, keyed by contract module. Each
handler is stored as a `DoubleDown.Dispatch.HandlerMeta.Stateful` struct
with an inline `:state` field. The dispatch machinery reads and writes
this state atomically via `NimbleOwnership.get_and_update` — every
`{result, new_state}` return from a handler is a single atomic operation.
Process isolation is handled by NimbleOwnership — each test process has
its own set of handlers and state, so `async: true` works without
cross-test interference.
## Handler arities
Stateful handlers come in two arities:
### 4-arity — own state only
```elixir
fn contract, operation, args, state -> {result, new_state} end
```
The handler receives its contract's current state and returns a new state.
This is the default for most fakes.
### 5-arity — cross-contract state access
```elixir
fn contract, operation, args, state, all_states ->
{result, new_state}
end
```
The 5th argument is a read-only snapshot of **all** contract states, keyed
by contract module. This enables the two-contract pattern where a queries
handler reads from the Repo's in-memory store:
```elixir
%{
DoubleDown.Repo => %{User => %{1 => %User{...}}},
MyApp.Queries => %{...},
}
```
A sentinel key (`DoubleDown.Contract.GlobalState`) detects accidental
returns of the global map — returning `all_states` instead of your own
state raises `ArgumentError`.
The global snapshot is taken before the `get_and_update` call — it's a
point-in-time view, not a live reference. Handlers can only update their
own contract's state via the return value.
## CanonicalHandlerState
When stateful handlers are installed via `DoubleDown.Double`,
Double wraps the user's state in a `CanonicalHandlerState` struct:
```
%CanonicalHandlerState{
fallback_state: user_state, # The user's domain state
expects: [...], # Ordered expect queue
stubs: %{op => [...]}, # Per-operation stubs
per_op_fakes: %{op => handler} # Per-operation fakes
}
```
This is an internal detail — user code only sees `fallback_state`
(extracted by `get_state/1`). The wrapping is what enables layered
expects/stubs/fakes over a stateful fallback: each sub-system (expect
queue, stub registry, fallback function) is tracked within the same
canonical state, allowing dispatch priority resolution inside a single
`get_and_update` call.
## State threading
State threads through sequenced operations:
```elixir
# Fallback writes to state
DoubleDown.Double.fallback(Contract, fn _c, :put, [k, v], state ->
{Map.put(state, k, v), Map.put(state, k, v)}
end, %{})
# 2-arity expect — reads state, returns it unchanged
DoubleDown.Double.expect(Contract, :get, fn [k], state ->
{Map.get(state, k), state}
end)
# 1-arity expect — stateless, doesn't touch state
DoubleDown.Double.expect(Contract, :ping, fn [_] -> :pong end)
```
When the 1-arity `:ping` expect fires, the state is unchanged — it sees
whatever state the fallback left it in, passes through without modifying
it, and the next expect sees the same state.
Stateful expects (2-arity, 3-arity) *must* return `{result, new_state}`
and can both read and update the state. Stateless expects (1-arity)
return a bare result and leave state untouched.
## StatefulHandler behaviour
For reusable stateful fakes, implement `DoubleDown.Dispatch.StatefulHandler`:
```elixir
defmodule MyApp.InMemoryStore do
@behaviour DoubleDown.Dispatch.StatefulHandler
@impl true
def new(seed, _opts), do: seed
@impl true
def dispatch(_contract, :get, [id], state), do: {Map.get(state, id), state}
def dispatch(_contract, :put, [id, val], state), do: {:ok, Map.put(state, id, val)}
end
```
Implement either `dispatch/4` or `dispatch/5` (or both — `/5` takes
priority when both are defined). Modules implementing this behaviour can
be used directly with `Double.fallback`:
```elixir
DoubleDown.Double.fallback(MyContract, MyApp.InMemoryStore)
```
## StatelessHandler behaviour
For reusable stateless stubs, implement
`DoubleDown.Dispatch.StatelessHandler`:
```elixir
defmodule MyApp.TestStore do
@behaviour DoubleDown.Dispatch.StatelessHandler
@impl true
def new(fallback_fn, _opts) do
fn contract, operation, args ->
case {operation, args} do
{:get, [id]} -> %{id: id}
_ when is_function(fallback_fn) -> fallback_fn.(contract, operation, args)
_ -> raise "unhandled: #{operation}/#{length(args)}"
end
end
end
end
```
`new/2` receives an optional fallback function and options, and returns a
3-arity function `(contract, operation, args) -> result`. Used with
`Double.fallback` by module name:
```elixir
DoubleDown.Double.fallback(MyContract, MyApp.TestStore)
```
If a fallback function is also supplied, it's passed as the first argument
to `new/2`:
```elixir
DoubleDown.Double.fallback(MyContract, MyApp.TestStore,
fn _contract, :all, [User] -> [] end
)
```
<!-- nav:footer:start -->
---
[< Dispatch](dispatch.md) | [Up: Guides](../README.md) | [Index](../README.md) | [Double API >](double-api.md)
<!-- nav:footer:end -->