Packages
double_down
0.28.1
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
lib/double_down/double.ex
defmodule DoubleDown.Double do
@moduledoc """
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 thing
## Repeated 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 3-arity `fn operation, args, state -> {result, new_state} end`
with real logic and state. Integrates fakes like `Repo.InMemory`
while allowing expects to override specific calls:
# First insert fails, rest go through InMemory
DoubleDown.Repo
|> DoubleDown.Double.fake(&Repo.InMemory.dispatch/3, Repo.InMemory.new())
|> DoubleDown.Double.expect(:insert, fn [changeset] ->
{:error, Ecto.Changeset.add_error(changeset, :email, "taken")}
end)
When an expect short-circuits (e.g. returning an error), the fake
state is unchanged — correct for error simulation.
**Limitation: no inline passthrough.** Expects and per-operation
stubs cannot delegate to a stateful fake inline — they produce
return values directly without access to the fake's state.
Threading mutable state through a user-provided callback requires
a complex API and seems of limited value given that the main use
case — error simulation — doesn't need it.
### 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.InMemory.dispatch/3, Repo.InMemory.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 |
| (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.
"""
@ownership_server DoubleDown.Dispatch.Ownership
@contracts_key DoubleDown.Double.Contracts
# -- Public API: expect --
@doc """
Add an expectation for a contract operation.
The function receives the argument list and returns the result.
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 function `n` times (default 1).
Equivalent to calling `expect` `n` times with the same function.
"""
@spec expect(module(), atom(), function() | :passthrough, keyword()) :: module()
def expect(contract, operation, fun_or_passthrough, opts \\ [])
def expect(contract, operation, fun, opts)
when is_atom(contract) and is_atom(operation) and is_function(fun, 1) and is_list(opts) do
do_expect(contract, operation, fun, opts)
end
def expect(contract, operation, :passthrough, opts)
when is_atom(contract) and is_atom(operation) and is_list(opts) do
do_expect(contract, operation, :passthrough, opts)
end
defp do_expect(contract, operation, fun_or_passthrough, opts) do
times = Keyword.get(opts, :times, 1)
if times < 1 do
raise ArgumentError, "times must be >= 1, got: #{times}"
end
entries = List.duplicate(fun_or_passthrough, times)
ensure_handler_installed(contract)
update_handler_state(contract, fn state ->
existing = Map.get(state.expects, operation, [])
%{state | expects: Map.put(state.expects, operation, existing ++ entries)}
end)
contract
end
# -- Public API: stub --
@doc """
Add a stub for a contract operation or a stateless function fallback.
## Per-operation stub (1-arity function)
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.
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)
For stateful fakes and module delegation, see `fake/2` and `fake/3`.
Dispatch priority: expects > per-operation stubs > fallback/fake > raise.
Function fallback, stateful fake, and module fake are mutually
exclusive — setting one replaces the other.
Returns the contract module for piping.
"""
# stub/2 — function fallback
@spec stub(module(), function()) :: module()
def stub(contract, fun)
when is_atom(contract) and is_function(fun, 2) do
ensure_handler_installed(contract)
update_handler_state(contract, fn state ->
%{state | fallback: {:fn, fun}}
end)
contract
end
# stub/3 — per-operation stub
@spec stub(module(), atom(), function()) :: module()
def stub(contract, operation, fun)
when is_atom(contract) and is_atom(operation) and is_function(fun, 1) do
ensure_handler_installed(contract)
update_handler_state(contract, fn state ->
%{state | stubs: Map.put(state.stubs, operation, fun)}
end)
contract
end
# -- Public API: fake --
@doc """
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`.
## Module fake
A module implementing the contract's `@behaviour`. 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
A 3-arity `fn operation, args, state -> {result, new_state} end`
with initial state. Same signature as `set_stateful_handler`,
allowing fakes like `Repo.InMemory` to integrate directly:
DoubleDown.Double.fake(MyContract, &Repo.InMemory.dispatch/3, Repo.InMemory.new())
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.
"""
# fake/2 — module fake
@spec fake(module(), module()) :: module()
def fake(contract, module)
when is_atom(contract) and is_atom(module) do
validate_module_fallback!(contract, module)
ensure_handler_installed(contract)
update_handler_state(contract, fn state ->
%{state | fallback: {:module, module}}
end)
contract
end
# fake/3 — stateful fake
@spec fake(module(), function(), term()) :: module()
def fake(contract, fun, init_state)
when is_atom(contract) and is_function(fun, 3) do
ensure_handler_installed(contract)
update_handler_state(contract, fn state ->
%{state | fallback: {:stateful, fun}, fallback_state: init_state}
end)
contract
end
# -- Public API: verify --
@doc """
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!() :: :ok
def verify!, do: do_verify!(self())
@doc """
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!(pid()) :: :ok
def verify!(pid) when is_pid(pid), do: do_verify!(pid)
@doc """
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!()
end
The verification runs in the on_exit callback (a separate process),
using the test pid captured at setup time.
"""
@spec verify_on_exit!(map()) :: :ok
def verify_on_exit!(_context \\ %{}) do
pid = self()
# Prevent NimbleOwnership from cleaning up when the test process
# exits — the data must survive until the on_exit callback runs.
NimbleOwnership.set_owner_to_manual_cleanup(@ownership_server, pid)
ExUnit.Callbacks.on_exit(DoubleDown.Double, fn ->
try do
verify!(pid)
after
# Clean up the ownership entries now that verification is done.
NimbleOwnership.cleanup_owner(@ownership_server, pid)
end
end)
:ok
end
# -- Internal: handler installation --
@initial_state %{contract: nil, expects: %{}, stubs: %{}, fallback: nil, fallback_state: nil}
defp ensure_handler_installed(contract) do
state_key = Module.concat(DoubleDown.State, contract)
# Check if we've already installed the handler for this contract
case NimbleOwnership.get_owned(@ownership_server, self()) do
%{^state_key => _} ->
:ok
_ ->
# First touch — install the canonical handler fn
DoubleDown.Testing.set_stateful_handler(
contract,
&canonical_handler/3,
%{@initial_state | contract: contract}
)
register_contract(contract)
end
end
defp register_contract(contract) do
NimbleOwnership.get_and_update(@ownership_server, self(), @contracts_key, fn
nil -> {:ok, [contract]}
existing -> {:ok, Enum.uniq([contract | existing])}
end)
end
defp update_handler_state(contract, update_fn) do
state_key = Module.concat(DoubleDown.State, contract)
NimbleOwnership.get_and_update(@ownership_server, self(), state_key, fn state ->
{:ok, update_fn.(state)}
end)
end
# -- Internal: canonical handler fn --
# This single function handles all dispatch. It reads expects, stubs,
# and fallback config from state at dispatch time. Installed once per
# contract via set_stateful_handler and never replaced — all changes
# go through state mutations.
@doc false
def canonical_handler(operation, args, state) do
case pop_expect(state, operation) do
{:ok, :passthrough, new_state} ->
invoke_fallback_or_raise(new_state, operation, args)
{:ok, fun, new_state} ->
{fun.(args), new_state}
:none ->
case Map.get(state.stubs, operation) do
nil ->
invoke_fallback_or_raise(state, operation, args)
stub_fun ->
{stub_fun.(args), state}
end
end
end
defp pop_expect(%{expects: expects} = state, operation) do
case Map.get(expects, operation, []) do
[entry | rest] ->
new_expects = Map.put(expects, operation, rest)
{:ok, entry, %{state | expects: new_expects}}
[] ->
:none
end
end
defp invoke_fallback_or_raise(state, operation, args) do
case state.fallback do
nil ->
msg = unexpected_call_message(state.contract, state, operation, args)
{%DoubleDown.Defer{fn: fn -> raise msg end}, state}
{:fn, fallback_fn} ->
invoke_fn_fallback(fallback_fn, state, operation, args)
{:stateful, fallback_fn} ->
invoke_stateful_fallback(fallback_fn, state, operation, args)
{:module, module} ->
invoke_module_fallback(module, state, operation, args)
end
end
defp invoke_fn_fallback(fallback_fn, state, operation, args) do
result = fallback_fn.(operation, args)
{result, state}
rescue
FunctionClauseError ->
msg = unexpected_call_message(state.contract, state, operation, args)
{%DoubleDown.Defer{fn: fn -> reraise msg, __STACKTRACE__ end}, state}
end
defp invoke_stateful_fallback(fallback_fn, state, operation, args) do
{result, new_fallback_state} = fallback_fn.(operation, args, state.fallback_state)
{result, %{state | fallback_state: new_fallback_state}}
rescue
FunctionClauseError ->
msg = unexpected_call_message(state.contract, state, operation, args)
{%DoubleDown.Defer{fn: fn -> reraise msg, __STACKTRACE__ end}, state}
end
defp invoke_module_fallback(module, state, operation, args) do
result = apply(module, operation, args)
{result, state}
rescue
UndefinedFunctionError ->
msg = unexpected_call_message(state.contract, state, operation, args)
{%DoubleDown.Defer{fn: fn -> reraise msg, __STACKTRACE__ end}, state}
end
defp unexpected_call_message(contract, %{expects: expects}, operation, args) do
remaining =
expects
|> Enum.reject(fn {_op, queue} -> queue == [] end)
|> Enum.map(fn {op, queue} -> " #{op}: #{length(queue)} expected call(s) remaining" end)
remaining_msg =
if remaining == [] do
" (no expectations remaining)"
else
Enum.join(remaining, "\n")
end
"""
Unexpected call to #{inspect(contract)}.#{operation}/#{length(args)}.
Args: #{inspect(args)}
No expectations or stubs defined for this operation.
Remaining expectations for #{inspect(contract)}:
#{remaining_msg}
"""
end
# -- Internal: verification --
defp do_verify!(pid) do
owned = NimbleOwnership.get_owned(@ownership_server, pid)
contracts =
case owned do
%{@contracts_key => contracts} ->
contracts
_ ->
raise "DoubleDown.Double.verify!/0 called but no handlers were installed"
end
unconsumed =
Enum.flat_map(contracts, fn contract ->
state_key = Module.concat(DoubleDown.State, contract)
case owned do
%{^state_key => %{expects: expects}} ->
expects
|> Enum.reject(fn {_op, queue} -> queue == [] end)
|> Enum.map(fn {op, queue} -> {contract, op, length(queue)} end)
_ ->
[]
end
end)
if unconsumed != [] do
details =
Enum.map_join(unconsumed, "\n", fn {contract, op, count} ->
" #{inspect(contract)}.#{op}: #{count} expected call(s) not made"
end)
raise """
DoubleDown.Double expectations not fulfilled:
#{details}
"""
end
:ok
end
# -- Internal: module fallback validation --
defp validate_module_fallback!(contract, module) do
unless Code.ensure_loaded?(module) do
raise ArgumentError,
"module fallback #{inspect(module)} for #{inspect(contract)} is not loaded"
end
Code.ensure_loaded(contract)
if function_exported?(contract, :__callbacks__, 0) do
operations = contract.__callbacks__()
missing =
Enum.reject(operations, fn %{name: name, arity: arity} ->
function_exported?(module, name, arity)
end)
if missing != [] do
details =
Enum.map_join(missing, ", ", fn %{name: name, arity: arity} ->
"#{name}/#{arity}"
end)
raise ArgumentError,
"module fallback #{inspect(module)} for #{inspect(contract)} " <>
"is missing functions: #{details}"
end
end
end
end