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: rejects > 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 / Mimic | DoubleDown.Double |
|---|---|
expect(Mock, :fn, n, fun) | expect(Contract, :fn, fun, times: n) |
reject(Mock, :fn, arity) | reject(Contract, :fn) |
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.
Reject an operation — assert it must not be called.
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: rejects > 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: rejects > 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)
Reject an operation — assert it must not be called.
If the rejected operation is called during the test, an error is
raised immediately. If it is never called, verify! passes (there
are no expectations to consume).
Equivalent to Mimic's reject/3.
MyContract
|> DoubleDown.Double.fallback(fn _contract, :list, [_] -> [] end)
|> DoubleDown.Double.reject(:delete)Returns the contract module for piping.
@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: rejects > 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.