DoubleDown.Double (double_down v0.55.0)

Copy Markdown View Source

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)

Fallback handlers

A fallback handles any operation without a specific expect, per-op fake, or per-op stub. Use Double.fallback/2..4 to install one:

# StatefulHandler module (recommended)
DoubleDown.Repo
|> DoubleDown.Double.fallback(Repo.OpenInMemory)
|> DoubleDown.Double.expect(:insert, fn [changeset] ->
  {:error, Ecto.Changeset.add_error(changeset, :email, "taken")}
end)

# Stateless function fallback
MyContract
|> DoubleDown.Double.fallback(fn _contract, operation, args ->
  case {operation, args} do
    {:list, [_]} -> []
    {:count, []} -> 0
  end
end)

See fallback/2 for all supported forms (StatefulHandler module, StatelessHandler module, module implementation, stateful function, stateless function).

Dispatch priority: expects > per-op fakes > per-op stubs > fallback > raise. Fallback types 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.fallback(MyApp.Impl)
|> DoubleDown.Double.expect(:get, :passthrough, times: 2)

Multi-contract

DoubleDown.Repo
|> DoubleDown.Double.fallback(&Repo.OpenInMemory.dispatch/4, Repo.OpenInMemory.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

MoxDoubleDown.Double
expect(Mock, :fn, n, fun)expect(Contract, :fn, fun, times: n)
stub(Mock, :fn, fun)stub(Contract, :fn, fun) — per-operation
(no equivalent)fallback(Contract, fn op, args -> ... end) — stateless fallback
(no equivalent)fallback(Contract, fn op, args, state -> ... end, init) — stateful fallback
(no equivalent)fallback(Contract, ImplModule) — module fallback
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_stateless_handler or set_stateful_handler — those remain for cases that don't fit the expect/stub/fake/fallback pattern.

Known limitations

FunctionClauseError in fallback bodies: When a stub or fake fallback function has no matching clause for an operation, DoubleDown catches the FunctionClauseError and raises a helpful "Unexpected call" error instead. However, if a fallback clause does match but its body internally calls a function that raises FunctionClauseError, that exception is also caught and misreported as "Unexpected call". This is a known limitation shared with Mox. If you see a surprising "Unexpected call" error, check whether your fallback body contains code that might raise FunctionClauseError.

Summary

Functions

Allow a child process to use the current process's test doubles.

Create a deferred execution marker for use in expect/stub/fake bodies.

Set up a dynamically-faked module with its original implementation as the fallback.

Add an expectation for a contract operation.

Add a per-operation fake for a contract operation.

Install a whole-contract fallback handler.

Return a passthrough sentinel for use in expect responders.

Add a per-operation stub for a contract operation.

Verify that all expectations have been consumed.

Verify expectations for a specific process.

Register an on_exit callback that verifies expectations after each test.

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 test doubles.

Delegates to DoubleDown.Testing.allow/3. Use this when spawning Tasks or other processes that need to dispatch through the same test handlers.

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

Also accepts a lazy pid function for processes that don't exist yet at setup time:

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

defer(fun)

@spec defer((-> term())) :: DoubleDown.Contract.Dispatch.Defer.t()

Create a deferred execution marker for use in expect/stub/fake bodies.

When a handler body needs to call another facade (including Backend.Repo), wrapping the call in defer/1 avoids deadlocking the NimbleOwnership GenServer. The deferred function runs outside the lock, in the calling process, after the handler's state update has been committed.

# Stateful fake that calls another contract:
Counter
|> Double.fallback(fn
  _contract, :increment, [n], count ->
    {Double.defer(fn ->
      greeting = Greeter.greet("from_counter")
      {greeting, n}
    end), count + n}

  _contract, :get_count, [], count ->
    {count, count}
end, 0)

# Stateless stub that calls Repo:
Double.stub(MyContract, :find_config, fn [org_id] ->
  Double.defer(fn ->
    Backend.Repo.get_by(Config, organisation_id: org_id)
  end)
end)

When you need defer: any time an expect, stub, or fake body calls through a facade — the handler runs inside a NimbleOwnership lock, and a nested dispatch would deadlock without it.

dynamic(module)

@spec dynamic(module()) :: module()

Set up a dynamically-faked module with its original implementation as the fallback.

Requires the module to have been set up with DoubleDown.DynamicFacade.setup/1. Layer expects and stubs on top:

SomeClient
|> DoubleDown.Double.dynamic()
|> DoubleDown.Double.expect(:fetch, fn [_] -> {:error, :timeout} end)

Calls without a matching expect or stub delegate to the original module's implementation.

Returns the module for piping.

expect(contract, operation, fun_or_passthrough, opts \\ [])

@spec expect(
  module(),
  atom(),
  DoubleDown.Double.Types.expect_fun() | :passthrough,
  keyword()
) ::
  module()

Add an expectation for a contract operation.

The responder function may be:

  • 1-arity fn [args] -> result end — stateless, returns a bare result
  • 2-arity fn [args], state -> {result, new_state} end — reads and updates the stateful fake's state. Requires a stateful fallback (fallback/2..4) first.
  • 3-arity fn [args], state, all_states -> {result, new_state} end — same as 2-arity plus a read-only snapshot of all contract states for cross-contract access. Requires a stateful fallback (fallback/2..4) first.

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.

fake(contract, operation, fun)

Add a per-operation fake for a contract operation.

Overrides a single operation with a stateful function that reads and updates the fallback's state. Requires a stateful fallback to be installed first via fallback/3:

DoubleDown.Repo
|> DoubleDown.Double.fallback(&Repo.OpenInMemory.dispatch/4, Repo.OpenInMemory.new())
|> DoubleDown.Double.fake(:insert, fn [changeset], state ->
  {{:error, add_error(changeset, :email, "taken")}, state}
end)

The function may be:

  • 2-arity fn [args], state -> {result, new_state} end
  • 3-arity fn [args], state, all_states -> {result, new_state} end (cross-contract state access)

Per-op fakes are permanent (not consumed like expects) and can return Double.passthrough() to delegate to the fallback. Setting a per-op fake twice for the same operation replaces the previous one.

For whole-contract fallback handlers, see fallback/2..4.

Dispatch priority: expects > per-op fakes > per-op stubs > fallback > raise.

Returns the contract module for piping.

fallback(contract, fun)

@spec fallback(module(), function() | module()) :: module()

Install a whole-contract fallback handler.

The fallback handles any operation not covered by an expect, per-op fake, or per-op stub. Several forms are supported:

A module implementing DoubleDown.Contract.Dispatch.StatefulHandler. The module's new/2 builds initial state, and its dispatch/4 or dispatch/5 handles operations:

# Default state
DoubleDown.Double.fallback(MyContract, Repo.OpenInMemory)

# With seed data
DoubleDown.Double.fallback(MyContract, Repo.OpenInMemory, [%User{id: 1}])

# With seed data and options
DoubleDown.Double.fallback(MyContract, Repo.OpenInMemory, [%User{id: 1}],
  fallback_fn: fn _contract, :all, [User], state -> Map.values(state[User]) end
)

StatelessHandler module

A module implementing DoubleDown.Contract.Dispatch.StatelessHandler. The module's new/2 builds a stateless dispatch function:

# Writes only — reads will raise
DoubleDown.Double.fallback(MyContract, DoubleDown.Repo.Stub)

# With a fallback function for reads
DoubleDown.Double.fallback(MyContract, DoubleDown.Repo.Stub,
  fn _contract, :all, [User] -> [] end)

Module implementation

A module implementing the contract's @behaviour (but not a StatefulHandler or StatelessHandler). All operations delegate via apply(module, operation, args):

DoubleDown.Double.fallback(MyContract, 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.

Stateful function

A 4-arity fn contract, operation, args, state -> {result, new_state} end or 5-arity fn contract, operation, args, state, all_states -> {result, new_state} end with initial state:

DoubleDown.Double.fallback(MyContract, &handler/4, initial_state)

Stateless function

A 3-arity fn contract, operation, args -> result end:

DoubleDown.Double.fallback(MyContract, fn _contract, operation, args ->
  case {operation, args} do
    {:list, [_]} -> []
    {:count, []} -> 0
  end
end)

Fallback types are mutually exclusive — setting one replaces the other.

Dispatch priority: expects > per-op fakes > per-op stubs > fallback > raise.

Returns the contract module for piping.

fallback(contract, fun, init_state)

@spec fallback(module(), function() | module(), term()) :: module()

fallback(contract, module, seed_or_fallback_fn, opts)

@spec fallback(module(), module(), term(), keyword()) :: module()

passthrough()

Return a passthrough sentinel for use in expect responders.

When returned from an expect responder, delegates the call to the fallback/fake as if the expect had been registered with :passthrough. The expect is still consumed for verify! counting.

This enables conditional passthrough — the responder can inspect the state and decide whether to handle the call or delegate:

DoubleDown.Repo
|> Double.fallback(&Repo.OpenInMemory.dispatch/4, Repo.OpenInMemory.new())
|> Double.expect(:insert, fn [changeset], state ->
  if duplicate?(state, changeset) do
    {{:error, add_error(changeset, :email, "taken")}, state}
  else
    Double.passthrough()
  end
end)

stub(contract, operation, fun)

Add a per-operation stub for a contract operation.

The function receives the argument list and returns the result. Stubs are stateless — for stateful per-operation overrides, use fake/3 or expect/4 with a 2-arity or 3-arity responder.

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.

The stub may return Double.passthrough() to delegate to the fallback for that specific call.

DoubleDown.Double.stub(MyContract, :list, fn [_] -> [] end)

For whole-contract fallback handlers, see fallback/2..4.

Dispatch priority: expects > per-op fakes > per-op stubs > fallback > raise.

Returns the contract module for piping.

verify!()

@spec verify!() :: :ok

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.

verify!(pid)

@spec verify!(pid()) :: :ok

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.

verify_on_exit!(context \\ %{})

@spec verify_on_exit!(map()) :: :ok

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.