Current section

Files

Jump to
double_down lib double_down double.ex
Raw

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