Packages
double_down
0.68.0
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/testing.ex
defmodule DoubleDown.Testing do
@moduledoc """
Test helpers for DoubleDown contracts.
Start the ownership server in `test/test_helper.exs`:
DoubleDown.Testing.start()
Then in your tests, register handlers per-contract:
setup do
DoubleDown.Testing.set_module_handler(MyApp.Todos, MyApp.Todos.InMemory)
:ok
end
Handlers are process-scoped via `NimbleOwnership`, so `async: true`
tests are isolated. Use `allow/3` to share handlers with child processes.
"""
alias DoubleDown.Dispatch.HandlerMeta
alias DoubleDown.Dispatch.Keys
@doc """
Start the DoubleDown ownership server.
Call this once in `test/test_helper.exs`.
"""
@spec start() :: {:ok, pid()} | {:error, term()}
def start do
result = NimbleOwnership.start_link(name: Keys.ownership_server())
DoubleDown.DynamicFacade.Meck.install()
result
end
@doc """
Register a module as the handler for a contract.
The module must implement the contract's `@behaviour`.
"""
@spec set_module_handler(module(), module()) :: :ok
def set_module_handler(contract, impl) do
set_meta(contract, HandlerMeta.Module.new(impl))
end
@doc """
Register a function as the handler for a contract.
The function receives `(contract, operation, args)` and returns the result.
"""
@spec set_stateless_handler(module(), (module(), atom(), [term()] -> term())) :: :ok
def set_stateless_handler(contract, fun) when is_function(fun, 3) do
set_meta(contract, HandlerMeta.Stateless.new(fun))
end
@doc """
Register a stateful handler for a contract.
The function may be 4-arity or 5-arity:
* **4-arity:** `fn contract, operation, args, state -> {result, new_state} end`
* **5-arity:** `fn contract, operation, args, state, all_states -> {result, new_state} end`
5-arity handlers receive a read-only snapshot of all contract states as
the 5th argument. This enables cross-contract state access (e.g. a Queries
handler reading the Repo InMemory store). The `all_states` map is keyed by
contract module and includes a `DoubleDown.Contract.GlobalState` sentinel key.
The handler must return only its own contract's new state — not the global map.
State is stored in NimbleOwnership and updated atomically on each dispatch.
"""
@spec set_stateful_handler(module(), DoubleDown.Dispatch.Types.stateful_fun(), term()) ::
:ok
def set_stateful_handler(contract, fun, initial_state)
when is_function(fun, 4) or is_function(fun, 5) do
set_meta(contract, HandlerMeta.Stateful.new(fun, initial_state))
end
@doc """
Allow a child process to use the current process's handlers.
Use this when spawning Tasks or other processes that need to
dispatch through the same test handlers.
"""
@spec allow(module(), pid() | (-> pid() | [pid()])) :: :ok | {:error, term()}
@spec allow(module(), pid(), pid() | (-> pid() | [pid()])) :: :ok | {:error, term()}
def allow(contract, owner_pid \\ self(), child_pid) do
NimbleOwnership.allow(Keys.ownership_server(), owner_pid, child_pid, contract)
end
@doc """
Enable dispatch logging for a contract.
After enabling, all dispatches through the contract's facade will be
recorded. Retrieve with `get_log/1`.
"""
@spec enable_log(module()) :: :ok
def enable_log(contract) do
log_key = Keys.log_key(contract)
NimbleOwnership.get_and_update(Keys.ownership_server(), self(), log_key, fn _ ->
{:ok, []}
end)
:ok
end
@doc """
Retrieve the dispatch log for a contract.
Returns a list of `{contract, operation, args, result}` tuples
in the order they were dispatched.
"""
@spec get_log(module()) :: [{module(), atom(), [term()], term()}]
def get_log(contract) do
log_key = Keys.log_key(contract)
case NimbleOwnership.get_owned(Keys.ownership_server(), self()) do
%{^log_key => log} -> Enum.reverse(log)
_ -> []
end
end
@doc """
Reset all handlers and logs for a process.
Clears all NimbleOwnership entries owned by `pid`. Defaults to
`self()`.
**`on_exit` caveat:** `reset()` (without arguments) uses `self()`,
which inside an `on_exit` callback is the callback process — not
the test process. To reset the test process's handlers from
`on_exit`, capture the pid first:
setup do
pid = self()
on_exit(fn -> DoubleDown.Testing.reset(pid) end)
:ok
end
In most cases you don't need explicit cleanup — NimbleOwnership
automatically cleans up when the owning process exits.
"""
@spec reset(pid()) :: :ok
def reset(pid \\ self()) do
NimbleOwnership.cleanup_owner(Keys.ownership_server(), pid)
end
# -- Internal --
defp set_meta(contract, meta) do
DoubleDown.DynamicFacade.ensure_shimmed(contract)
case NimbleOwnership.get_and_update(Keys.ownership_server(), self(), contract, fn
nil ->
{:ok, meta}
existing ->
{{:error, existing}, existing}
end) do
{:ok, {:error, existing}} ->
raise ArgumentError, """
A handler is already installed for #{inspect(contract)}.
Found: #{inspect(existing)}
Call DoubleDown.Testing.reset() first to clear all handlers \
before installing a new one.
"""
{:ok, _} ->
:ok
{:error, %NimbleOwnership.Error{} = error} ->
raise ArgumentError, """
Failed to install handler for #{inspect(contract)}: \
#{Exception.message(error)}
Ensure the handler is installed from the test process's \
setup block, not from a child process.
"""
end
end
end