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)Stubs and fakes as fallbacks
A fallback handles any operation without a specific expect or per-operation stub. Stubs and fakes serve different purposes:
Function fallback (stub)
A stateless 2-arity fn operation, args -> result end — canned
responses, same signature as set_fn_handler:
MyContract
|> DoubleDown.Double.expect(:get, fn [id] -> %Thing{id: id} end)
|> DoubleDown.Double.stub(fn
:list, [_] -> []
:count, [] -> 0
end)Stateful fake
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 real logic and state. Integrates fakes like Repo.OpenInMemory
while allowing expects to override specific calls. 5-arity fakes
receive a read-only snapshot of all contract states for
cross-contract state access:
# First insert fails, rest go through InMemory
DoubleDown.Repo
|> DoubleDown.Double.fake(&Repo.OpenInMemory.dispatch/4, Repo.OpenInMemory.new())
|> DoubleDown.Double.expect(:insert, fn [changeset] ->
{:error, Ecto.Changeset.add_error(changeset, :email, "taken")}
end)When a 1-arity expect short-circuits (e.g. returning an error), the fake state is unchanged — correct for error simulation.
Expects can also be stateful — 2-arity and 3-arity responders receive the fake's state and can update it:
# 2-arity: access and update the fake's state
|> DoubleDown.Double.expect(:insert, fn [changeset], state ->
{result, new_state}
end)
# 3-arity: cross-contract state access too
|> DoubleDown.Double.expect(:insert, fn [changeset], state, all_states ->
{result, new_state}
end)Stateful responders require fake/3 to be called first (the fake
provides the state). They must return {result, new_state}.
Module fake
A module implementing the contract's @behaviour:
MyContract
|> DoubleDown.Double.expect(:get, fn [_] -> {:error, :not_found} end)
|> DoubleDown.Double.fake(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. For stubs to be visible, the
module must call through the facade.
Dispatch priority: expects > per-operation stubs > fallback/fake > raise. Function stub, stateful fake, and module fake 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.fake(MyApp.Impl)
|> DoubleDown.Double.expect(:get, :passthrough, times: 2)Multi-contract
DoubleDown.Repo
|> DoubleDown.Double.fake(&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) | stub(Contract, fn op, args -> ... end) — function fallback |
| (no equivalent) | fake(Contract, fn op, args, state -> ... end, init) — stateful fake (3 or 4-arity) |
| (no equivalent) | fake(Contract, ImplModule) — module fake |
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_fn_handler or set_stateful_handler —
those remain for cases that don't fit the expect/stub pattern.
Summary
Functions
Allow a child process to use the current process's test doubles.
Set up a dynamically-faked module with its original implementation as the fallback.
Add an expectation for a contract operation.
Set a fake implementation as the fallback for a contract.
Return a passthrough sentinel for use in expect responders.
Add a stub for a contract operation or a stateless function fallback.
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)
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.
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. Requiresfake/3first. - 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. Requiresfake/3first.
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.
Set a fake implementation as the fallback for a contract.
Fakes have real logic — they maintain state or delegate to a real
implementation module. They handle any operation not covered by an
expect or per-operation stub.
FakeHandler module (recommended for stateful fakes)
A module implementing DoubleDown.Contract.Dispatch.FakeHandler. The module's
new/2 builds initial state, and its dispatch/4 or dispatch/5
handles operations:
# Default state
DoubleDown.Double.fake(MyContract, Repo.OpenInMemory)
# With seed data
DoubleDown.Double.fake(MyContract, Repo.OpenInMemory, [%User{id: 1}])
# With seed data and options
DoubleDown.Double.fake(MyContract, Repo.OpenInMemory, [%User{id: 1}],
fallback_fn: fn :all, [User], state -> Map.values(state[User]) end
)Module fake
A module implementing the contract's @behaviour (but not FakeHandler).
All unhandled operations delegate via apply(module, operation, args):
DoubleDown.Double.fake(MyContract, MyApp.Impl)The module is validated immediately — all contract operations must be exported.
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. For stubs to be visible, the
module must call through the facade.
Stateful fake 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.fake(MyContract, &handler/4, initial_state)The fake's state is threaded through calls automatically. When an expect short-circuits (e.g. returning an error), the fake state is unchanged — correct for error simulation.
Dispatch priority: expects > per-operation stubs > fake > raise.
Function fallback (stub/2), module fake, and stateful fake are
mutually exclusive — setting one replaces the other.
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.fake(&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)
Add a stub for a contract operation or a stateless function fallback.
Per-operation stub
The function receives the argument list and returns the result. 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 function may be:
- 1-arity
fn [args] -> result end— stateless - 2-arity
fn [args], state -> {result, new_state} end— stateful (requiresfake/3first) - 3-arity
fn [args], state, all_states -> {result, new_state} end— cross-contract state access (requiresfake/3first)
Any arity may return Double.passthrough() to delegate to the
fallback/fake for that specific call.
DoubleDown.Double.stub(MyContract, :list, fn [_] -> [] end)Function fallback (2-arity function)
When the function is 2-arity fn operation, args -> result end,
it acts as a fallback for any operation on the contract that has
no per-operation expect or stub. This is the same signature as
set_fn_handler, so existing handler functions can be reused:
DoubleDown.Double.stub(MyContract, fn
:list, [_] -> []
:count, [] -> 0
end)StubHandler module
A module implementing DoubleDown.Contract.Dispatch.StubHandler. The module's
new/2 builds a dispatch function from an optional fallback:
# Writes only — reads will raise
DoubleDown.Double.stub(MyContract, DoubleDown.Repo.Stub)
# With fallback for reads
DoubleDown.Double.stub(MyContract, DoubleDown.Repo.Stub,
fn :all, [User] -> [] end)For stateful fakes and module delegation, see fake/2 and fake/3.
Dispatch priority: expects > per-operation stubs > fallback/fake > raise. Function fallback, StubHandler, stateful fake, and module fake are mutually exclusive — setting one replaces the other.
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.