< Logging | Up: README | Repo >

All test doubles are process-scoped. async: true tests run in full isolation — each test process has its own doubles, state, and logs.

Task.async children

Task.async children automatically inherit their parent's doubles via the $callers chain. No setup needed.

Explicit sharing with allow

Other processes (plain spawn, Agent, GenServer) need explicit sharing:

DoubleDown.Double.allow(MyApp.Todos, self(), agent_pid)

allow/3 also accepts a lazy pid function for processes that don't exist yet at setup time:

DoubleDown.Double.allow(MyApp.Todos, fn -> GenServer.whereis(MyWorker) end)

Global mode

For integration-style tests involving supervision trees, named GenServers, Broadway pipelines, or Oban workers — where individual process pids are not easily accessible — you can switch to global mode:

setup do
  DoubleDown.Testing.set_mode_to_global()

  DoubleDown.Double.fake(DoubleDown.Repo, DoubleDown.Repo.InMemory)

  on_exit(fn -> DoubleDown.Testing.set_mode_to_private() end)
  :ok
end

In global mode, all doubles registered by the test process are visible to every process in the VM without explicit allow/3 calls.

Warning: Global mode is incompatible with async: true. When active, all tests share the same doubles, so concurrent tests will interfere with each other. Only use global mode in tests with async: false. Call set_mode_to_private/0 in on_exit to restore per-process isolation for subsequent tests.

Choosing the right approach

SituationApproachasync: true?
Direct function callsNo extra setup neededYes
Task.async / Task.SupervisorAutomatic via $callersYes
Known pid (Agent, named GenServer)allow/3 with the pidYes
Pid not known at setup timeallow/3 with lazy fnYes
Supervision tree / Broadway / Obanset_mode_to_global/0No

Example: testing a GenServer that dispatches through a contract

defmodule MyApp.WorkerTest do
  use ExUnit.Case, async: true

  setup do
    MyApp.Todos
    |> DoubleDown.Double.stub(:get_todo, fn [id] -> {:ok, %Todo{id: id}} end)

    {:ok, pid} = MyApp.Worker.start_link([])
    DoubleDown.Double.allow(MyApp.Todos, self(), pid)

    %{worker: pid}
  end

  test "worker fetches todo via contract", %{worker: pid} do
    assert {:ok, %Todo{id: "42"}} = MyApp.Worker.fetch(pid, "42")
  end
end

Example: testing through a supervision tree

When you can't easily get pids for every process in the tree, use global mode:

defmodule MyApp.PipelineIntegrationTest do
  use ExUnit.Case, async: false

  setup do
    DoubleDown.Testing.set_mode_to_global()

    DoubleDown.Double.fake(DoubleDown.Repo, DoubleDown.Repo.InMemory)

    on_exit(fn -> DoubleDown.Testing.set_mode_to_private() end)

    start_supervised!(MyApp.Pipeline)
    :ok
  end

  test "pipeline processes events end-to-end" do
    MyApp.Pipeline.enqueue(%{type: :invoice, amount: 100})
    # ... assert on results ...
  end
end

Cleanup

Call reset/0 to clear all doubles, state, and logs for the current process:

setup do
  DoubleDown.Testing.reset()
  # ... set up fresh doubles ...
end

In practice, most tests just set doubles in setup without calling reset — NimbleOwnership's per-process isolation means there's no cross-test leakage.


< Logging | Up: README | Repo >