DoubleDown.Testing (double_down v0.60.2)

Copy Markdown View Source

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.

Summary

Functions

Allow a child process to use the current process's handlers.

Enable dispatch logging for a contract.

Retrieve the dispatch log for a contract.

Reset all handlers and logs for a process.

Select private or global mode based on the test context.

Set the ownership server to global mode.

Restore the ownership server to private (per-process) mode.

Register a module as the handler for a contract.

Register a stateful handler for a contract.

Register a function as the handler for a contract.

Start the DoubleDown ownership server.

Functions

allow(contract, owner_pid \\ self(), child_pid)

@spec allow(module(), pid(), pid() | (-> pid() | [pid()])) :: :ok | {:error, term()}

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.

enable_log(contract)

@spec enable_log(module()) :: :ok

Enable dispatch logging for a contract.

After enabling, all dispatches through the contract's facade will be recorded. Retrieve with get_log/1.

get_log(contract)

@spec get_log(module()) :: [{module(), atom(), [term()], term()}]

Retrieve the dispatch log for a contract.

Returns a list of {contract, operation, args, result} tuples in the order they were dispatched.

reset(pid \\ self())

@spec reset(pid()) :: :ok

Reset all handlers and logs for a process.

Clears all NimbleOwnership entries owned by pid and reverts the ownership server to private mode (in case global mode was active). 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.

set_mode_from_context(context \\ %{})

@spec set_mode_from_context(%{async: boolean()} | map()) :: :ok

Select private or global mode based on the test context.

When async: true is set on the test module, private mode is used (the default — each test process has its own handlers). Otherwise, global mode is used (all processes share the calling process's handlers).

Use as a setup callback — this is the recommended way to manage mode selection, mirroring Mox's set_mox_from_context:

# In your test module:
use ExUnit.Case, async: false

setup :set_mode_from_context

setup do
  DoubleDown.Testing.set_module_handler(MyApp.Repo, MyApp.Repo.InMemory)
  :ok
end

Because set_mode_from_context runs at the start of every test, it always resets the mode — even if a previous test crashed without cleaning up. NimbleOwnership's automatic :DOWN handler removes owned keys when the test process exits, so no explicit on_exit cleanup is needed.

For async: true tests, set_mode_from_context is a no-op (private mode is the default), so it's safe to include unconditionally.

set_mode_to_global()

@spec set_mode_to_global() :: :ok

Set the ownership server to global mode.

In global mode, all handlers registered by the calling process are visible to every process in the VM — no allow/3 calls needed. This is useful for integration-style tests that involve supervision trees, named GenServers, Broadway pipelines, or Oban workers where individual process pids are not easily accessible.

The calling process becomes the "shared owner". Only the shared owner process can install handlers — calls to set_*_handler from other processes will raise ArgumentError.

Prefer set_mode_from_context/1 over calling set_mode_to_global/0 directly — it handles mode selection automatically and is more robust against stale state from previous tests:

setup :set_mode_from_context

setup do
  DoubleDown.Testing.set_module_handler(MyApp.Repo, MyApp.Repo.InMemory)
  :ok
end

Warning

Global mode is incompatible with async: true. When global mode is active, all tests share the same handlers, so concurrent tests will interfere with each other. Only use global mode in tests with async: false.

Common mistakes

Don't use setup_all for global mode. setup_all runs in a different process than setup/tests, so handlers installed in setup_all won't be visible to test processes in global mode:

# BROKEN — setup_all and setup run in different processes
setup_all do
  DoubleDown.Testing.set_mode_to_global()
  :ok
end

setup do
  # This RAISES — self() is not the shared owner
  DoubleDown.Testing.set_module_handler(MyContract, MyImpl)
end

set_mode_to_private()

@spec set_mode_to_private() :: :ok

Restore the ownership server to private (per-process) mode.

After calling this, handlers are once again scoped to the process that registered them. Use this to clean up after set_mode_to_global/0.

set_module_handler(contract, impl)

@spec set_module_handler(module(), module()) :: :ok

Register a module as the handler for a contract.

The module must implement the contract's @behaviour.

set_stateful_handler(contract, fun, initial_state)

@spec set_stateful_handler(
  module(),
  DoubleDown.Contract.Dispatch.Types.stateful_fun(),
  term()
) :: :ok

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.

set_stateless_handler(contract, fun)

@spec set_stateless_handler(module(), (module(), atom(), [term()] -> term())) :: :ok

Register a function as the handler for a contract.

The function receives (contract, operation, args) and returns the result.

start()

@spec start() :: {:ok, pid()} | {:error, term()}

Start the DoubleDown ownership server.

Call this once in test/test_helper.exs.