Mox-style expect/stub handler declarations with immediate effect.
Each expect and stub call writes directly to NimbleOwnership —
no builder struct, no install! step. Functions return the contract
module atom for Mimic-style piping.
Basic usage
DoubleDown.Double.expect(MyContract, :get_thing, fn [id] -> %Thing{id: id} end)
DoubleDown.Double.stub(MyContract, :list, fn [_] -> [] end)
# ... run code under test ...
DoubleDown.Double.verify!()Piping
All functions return the contract module, so you can pipe:
MyContract
|> DoubleDown.Double.expect(:get_thing, fn [id] -> %Thing{id: id} end)
|> DoubleDown.Double.stub(:list, fn [_] -> [] end)Sequenced expectations
Successive calls to expect for the same operation queue handlers
that are consumed in order:
MyContract
|> DoubleDown.Double.expect(:get_thing, fn [_] -> {:error, :not_found} end)
|> DoubleDown.Double.expect(:get_thing, fn [id] -> %Thing{id: id} end)
# First call returns :not_found, second returns the thingRepeated expectations
Use times: n when the same function should handle multiple calls:
DoubleDown.Double.expect(MyContract, :check, fn [_] -> :ok end, times: 3)Expects + stubs
When an operation has both expects and a stub, expects are consumed first; once exhausted, the stub handles all subsequent calls:
MyContract
|> DoubleDown.Double.expect(:get, fn [_] -> :first end)
|> DoubleDown.Double.stub(:get, fn [_] -> :default end)Fallback handlers
A fallback handles any operation without a specific expect, per-op
fake, or per-op stub. Use Double.fallback/2..4 to install one:
# StatefulHandler module (recommended)
DoubleDown.Repo
|> DoubleDown.Double.fallback(Repo.OpenInMemory)
|> DoubleDown.Double.expect(:insert, fn [changeset] ->
{:error, Ecto.Changeset.add_error(changeset, :email, "taken")}
end)
# Stateless function fallback
MyContract
|> DoubleDown.Double.fallback(fn _contract, operation, args ->
case {operation, args} do
{:list, [_]} -> []
{:count, []} -> 0
end
end)See fallback/2 for all supported forms (StatefulHandler module,
StatelessHandler module, module implementation, stateful function,
stateless function).
Dispatch priority: expects > per-op fakes > per-op stubs > fallback > raise. Fallback types are mutually exclusive — setting one replaces the other.
Passthrough expects
When a fallback/fake is configured, pass :passthrough instead of
a function to delegate while still consuming the expect for
verify! counting:
MyContract
|> DoubleDown.Double.fallback(MyApp.Impl)
|> DoubleDown.Double.expect(:get, :passthrough, times: 2)Multi-contract
DoubleDown.Repo
|> DoubleDown.Double.fallback(&Repo.OpenInMemory.dispatch/4, Repo.OpenInMemory.new())
|> DoubleDown.Double.expect(:insert, fn [cs] -> {:error, :taken} end)
QueriesContract
|> DoubleDown.Double.expect(:get_record, fn [id] -> %Record{id: id} end)Relationship to Mox
| Mox | DoubleDown.Double |
|---|---|
expect(Mock, :fn, n, fun) | expect(Contract, :fn, fun, times: n) |
stub(Mock, :fn, fun) | stub(Contract, :fn, fun) — per-operation |
| (no equivalent) | fallback(Contract, fn op, args -> ... end) — stateless fallback |
| (no equivalent) | fallback(Contract, fn op, args, state -> ... end, init) — stateful fallback |
| (no equivalent) | fallback(Contract, ImplModule) — module fallback |
verify!() | verify!() |
verify_on_exit!() | verify_on_exit!() |
Mox.defmock(Mock, for: Behaviour) | Not needed |
Application.put_env(...) | Not needed |
Relationship to existing APIs
This is a higher-level convenience built on set_stateful_handler.
It does not replace set_stateless_handler or set_stateful_handler —
those remain for cases that don't fit the expect/stub/fake/fallback pattern.
Known limitations
FunctionClauseError in fallback bodies: When a stub or fake
fallback function has no matching clause for an operation, DoubleDown
catches the FunctionClauseError and raises a helpful "Unexpected
call" error instead. However, if a fallback clause does match but
its body internally calls a function that raises FunctionClauseError,
that exception is also caught and misreported as "Unexpected call".
This is a known limitation shared with Mox. If you see a surprising
"Unexpected call" error, check whether your fallback body contains
code that might raise FunctionClauseError.
Summary
Functions
Allow a child process to use the current process's test doubles.
Create a deferred execution marker for use in expect/stub/fake bodies.
Set up a dynamically-faked module with its original implementation as the fallback.
Add an expectation for a contract operation.
Add a per-operation fake for a contract operation.
Install a whole-contract fallback handler.
Return a passthrough sentinel for use in expect responders.
Add a per-operation stub for a contract operation.
Verify that all expectations have been consumed.
Verify expectations for a specific process.
Register an on_exit callback that verifies expectations after
each test.
Functions
Allow a child process to use the current process's test doubles.
Delegates to DoubleDown.Testing.allow/3. Use this when spawning
Tasks or other processes that need to dispatch through the same
test handlers.
{:ok, pid} = MyApp.Worker.start_link([])
DoubleDown.Double.allow(MyContract, pid)Also accepts a lazy pid function for processes that don't exist yet at setup time:
DoubleDown.Double.allow(MyContract, fn -> GenServer.whereis(MyWorker) end)
@spec defer((-> term())) :: DoubleDown.Contract.Dispatch.Defer.t()
Create a deferred execution marker for use in expect/stub/fake bodies.
When a handler body needs to call another facade (including
Backend.Repo), wrapping the call in defer/1 avoids deadlocking
the NimbleOwnership GenServer. The deferred function runs outside
the lock, in the calling process, after the handler's state update
has been committed.
# Stateful fake that calls another contract:
Counter
|> Double.fallback(fn
_contract, :increment, [n], count ->
{Double.defer(fn ->
greeting = Greeter.greet("from_counter")
{greeting, n}
end), count + n}
_contract, :get_count, [], count ->
{count, count}
end, 0)
# Stateless stub that calls Repo:
Double.stub(MyContract, :find_config, fn [org_id] ->
Double.defer(fn ->
Backend.Repo.get_by(Config, organisation_id: org_id)
end)
end)When you need defer: any time an expect, stub, or fake body
calls through a facade — the handler runs inside a NimbleOwnership
lock, and a nested dispatch would deadlock without it.
Set up a dynamically-faked module with its original implementation as the fallback.
Requires the module to have been set up with
DoubleDown.DynamicFacade.setup/1. Layer expects and stubs on top:
SomeClient
|> DoubleDown.Double.dynamic()
|> DoubleDown.Double.expect(:fetch, fn [_] -> {:error, :timeout} end)Calls without a matching expect or stub delegate to the original module's implementation.
Returns the module for piping.
@spec expect( module(), atom(), DoubleDown.Double.Types.expect_fun() | :passthrough, keyword() ) :: module()
Add an expectation for a contract operation.
The responder function may be:
- 1-arity
fn [args] -> result end— stateless, returns a bare result - 2-arity
fn [args], state -> {result, new_state} end— reads and updates the stateful fake's state. Requires a stateful fallback (fallback/2..4) first. - 3-arity
fn [args], state, all_states -> {result, new_state} end— same as 2-arity plus a read-only snapshot of all contract states for cross-contract access. Requires a stateful fallback (fallback/2..4) first.
Expectations are consumed in order — the first expect for an
operation handles the first call, the second handles the second,
and so on.
Instead of a function, pass :passthrough to delegate to the
fallback (fn, stateful, or module) while still consuming the
expect for verify! counting.
Returns the contract module for piping.
Options
:times— enqueue the same functionntimes (default 1). Equivalent to callingexpectntimes with the same function.
@spec fake(module(), atom(), DoubleDown.Double.Types.fake_fun()) :: module()
Add a per-operation fake for a contract operation.
Overrides a single operation with a stateful function that reads
and updates the fallback's state. Requires a stateful fallback
to be installed first via fallback/3:
DoubleDown.Repo
|> DoubleDown.Double.fallback(&Repo.OpenInMemory.dispatch/4, Repo.OpenInMemory.new())
|> DoubleDown.Double.fake(:insert, fn [changeset], state ->
{{:error, add_error(changeset, :email, "taken")}, state}
end)The function may be:
- 2-arity
fn [args], state -> {result, new_state} end - 3-arity
fn [args], state, all_states -> {result, new_state} end(cross-contract state access)
Per-op fakes are permanent (not consumed like expects) and can
return Double.passthrough() to delegate to the fallback.
Setting a per-op fake twice for the same operation replaces the
previous one.
For whole-contract fallback handlers, see fallback/2..4.
Dispatch priority: expects > per-op fakes > per-op stubs > fallback > raise.
Returns the contract module for piping.
Install a whole-contract fallback handler.
The fallback handles any operation not covered by an expect,
per-op fake, or per-op stub. Several forms are supported:
StatefulHandler module (recommended for stateful fallbacks)
A module implementing DoubleDown.Contract.Dispatch.StatefulHandler.
The module's new/2 builds initial state, and its dispatch/4 or
dispatch/5 handles operations:
# Default state
DoubleDown.Double.fallback(MyContract, Repo.OpenInMemory)
# With seed data
DoubleDown.Double.fallback(MyContract, Repo.OpenInMemory, [%User{id: 1}])
# With seed data and options
DoubleDown.Double.fallback(MyContract, Repo.OpenInMemory, [%User{id: 1}],
fallback_fn: fn _contract, :all, [User], state -> Map.values(state[User]) end
)StatelessHandler module
A module implementing DoubleDown.Contract.Dispatch.StatelessHandler.
The module's new/2 builds a stateless dispatch function:
# Writes only — reads will raise
DoubleDown.Double.fallback(MyContract, DoubleDown.Repo.Stub)
# With a fallback function for reads
DoubleDown.Double.fallback(MyContract, DoubleDown.Repo.Stub,
fn _contract, :all, [User] -> [] end)Module implementation
A module implementing the contract's @behaviour (but not a
StatefulHandler or StatelessHandler). All operations delegate via
apply(module, operation, args):
DoubleDown.Double.fallback(MyContract, MyApp.Impl)Mimic-style limitation: if the module's :bar internally calls
:foo, and you've stubbed :foo, the module won't see your stub —
it calls its own :foo directly.
Stateful function
A 4-arity fn contract, operation, args, state -> {result, new_state} end
or 5-arity fn contract, operation, args, state, all_states -> {result, new_state} end
with initial state:
DoubleDown.Double.fallback(MyContract, &handler/4, initial_state)Stateless function
A 3-arity fn contract, operation, args -> result end:
DoubleDown.Double.fallback(MyContract, fn _contract, operation, args ->
case {operation, args} do
{:list, [_]} -> []
{:count, []} -> 0
end
end)Fallback types are mutually exclusive — setting one replaces the other.
Dispatch priority: expects > per-op fakes > per-op stubs > fallback > raise.
Returns the contract module for piping.
@spec passthrough() :: DoubleDown.Contract.Dispatch.Passthrough.t()
Return a passthrough sentinel for use in expect responders.
When returned from an expect responder, delegates the call to the
fallback/fake as if the expect had been registered with :passthrough.
The expect is still consumed for verify! counting.
This enables conditional passthrough — the responder can inspect the state and decide whether to handle the call or delegate:
DoubleDown.Repo
|> Double.fallback(&Repo.OpenInMemory.dispatch/4, Repo.OpenInMemory.new())
|> Double.expect(:insert, fn [changeset], state ->
if duplicate?(state, changeset) do
{{:error, add_error(changeset, :email, "taken")}, state}
else
Double.passthrough()
end
end)
@spec stub(module(), atom(), DoubleDown.Double.Types.stub_fun()) :: module()
Add a per-operation stub for a contract operation.
The function receives the argument list and returns the result.
Stubs are stateless — for stateful per-operation overrides, use
fake/3 or expect/4 with a 2-arity or 3-arity responder.
Stubs handle any number of calls and are used after all expectations for an operation are consumed. Setting a stub twice for the same operation replaces the previous one.
The stub may return Double.passthrough() to delegate to the
fallback for that specific call.
DoubleDown.Double.stub(MyContract, :list, fn [_] -> [] end)For whole-contract fallback handlers, see fallback/2..4.
Dispatch priority: expects > per-op fakes > per-op stubs > fallback > raise.
Returns the contract module for piping.
@spec verify!() :: :ok
Verify that all expectations have been consumed.
Reads the current handler state for each contract and checks that all expect queues are empty. Stubs are not checked — they are allowed to be called zero or more times.
Raises with a descriptive message if any expectations remain unconsumed.
Returns :ok if all expectations are satisfied.
@spec verify!(pid()) :: :ok
Verify expectations for a specific process.
Same as verify!/0 but checks the expectations owned by pid
instead of the calling process. Used internally by verify_on_exit!/0.
@spec verify_on_exit!(map()) :: :ok
Register an on_exit callback that verifies expectations after
each test.
Call this in a setup block so that tests which forget to call
verify!/0 explicitly still fail on unconsumed expectations:
setup :verify_on_exit!Or equivalently:
setup do
DoubleDown.Double.verify_on_exit!()
endThe verification runs in the on_exit callback (a separate process), using the test pid captured at setup time.