DoubleDown.Double (double_down v0.58.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: rejects > 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

Mox / MimicDoubleDown.Double
expect(Mock, :fn, n, fun)expect(Contract, :fn, fun, times: n)
reject(Mock, :fn, arity)reject(Contract, :fn)
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.

Reject an operation/arity — assert it must not be called.

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: rejects > 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: rejects > 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)

reject(contract, operation, arity)

@spec reject(module(), atom(), non_neg_integer()) :: module()

Reject an operation/arity — assert it must not be called.

If the rejected operation is called with the specified arity during the test, an error is raised immediately. If it is never called, verify! passes (there are no expectations to consume).

The arity parameter is the number of arguments the operation receives (matching Mimic's reject/3 signature). Different arities of the same operation can be independently rejected or expected:

MyContract
|> DoubleDown.Double.fallback(fn _contract, :fetch, [_id] -> nil end)
|> DoubleDown.Double.reject(:delete, 1)

# fetch/1 still works, delete/1 raises if called

Returns the contract module for piping.

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: rejects > 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.