DoubleDown.Double (double_down v0.47.2)

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)

Stubs and fakes as fallbacks

A fallback handles any operation without a specific expect or per-operation stub. Stubs and fakes serve different purposes:

Function fallback (stub)

A stateless 2-arity fn operation, args -> result end — canned responses, same signature as set_fn_handler:

MyContract
|> DoubleDown.Double.expect(:get, fn [id] -> %Thing{id: id} end)
|> DoubleDown.Double.stub(fn
  :list, [_] -> []
  :count, [] -> 0
end)

Stateful fake

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 real logic and state. Integrates fakes like Repo.OpenInMemory while allowing expects to override specific calls. 5-arity fakes receive a read-only snapshot of all contract states for cross-contract state access:

# First insert fails, rest go through InMemory
DoubleDown.Repo
|> DoubleDown.Double.fake(&Repo.OpenInMemory.dispatch/4, Repo.OpenInMemory.new())
|> DoubleDown.Double.expect(:insert, fn [changeset] ->
  {:error, Ecto.Changeset.add_error(changeset, :email, "taken")}
end)

When a 1-arity expect short-circuits (e.g. returning an error), the fake state is unchanged — correct for error simulation.

Expects can also be stateful — 2-arity and 3-arity responders receive the fake's state and can update it:

# 2-arity: access and update the fake's state
|> DoubleDown.Double.expect(:insert, fn [changeset], state ->
  {result, new_state}
end)

# 3-arity: cross-contract state access too
|> DoubleDown.Double.expect(:insert, fn [changeset], state, all_states ->
  {result, new_state}
end)

Stateful responders require fake/3 to be called first (the fake provides the state). They must return {result, new_state}.

Module fake

A module implementing the contract's @behaviour:

MyContract
|> DoubleDown.Double.expect(:get, fn [_] -> {:error, :not_found} end)
|> DoubleDown.Double.fake(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. For stubs to be visible, the module must call through the facade.

Dispatch priority: expects > per-operation stubs > fallback/fake > raise. Function stub, stateful fake, and module fake 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.fake(MyApp.Impl)
|> DoubleDown.Double.expect(:get, :passthrough, times: 2)

Multi-contract

DoubleDown.Repo
|> DoubleDown.Double.fake(&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)stub(Contract, fn op, args -> ... end) — function fallback
(no equivalent)fake(Contract, fn op, args, state -> ... end, init) — stateful fake (3 or 4-arity)
(no equivalent)fake(Contract, ImplModule) — module fake
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_fn_handler or set_stateful_handler — those remain for cases that don't fit the expect/stub pattern.

Summary

Functions

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

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

Add an expectation for a contract operation.

Set a fake implementation as the fallback for a contract.

Return a passthrough sentinel for use in expect responders.

Add a stub for a contract operation or a stateless function fallback.

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)

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(), function() | :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 fake/3 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 fake/3 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, module)

@spec fake(module(), module()) :: module()

Set a fake implementation as the fallback for a contract.

Fakes have real logic — they maintain state or delegate to a real implementation module. They handle any operation not covered by an expect or per-operation stub.

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

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

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

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

Module fake

A module implementing the contract's @behaviour (but not FakeHandler). All unhandled operations delegate via apply(module, operation, args):

DoubleDown.Double.fake(MyContract, MyApp.Impl)

The module is validated immediately — all contract operations must be exported.

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. For stubs to be visible, the module must call through the facade.

Stateful fake 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.fake(MyContract, &handler/4, initial_state)

The fake's state is threaded through calls automatically. When an expect short-circuits (e.g. returning an error), the fake state is unchanged — correct for error simulation.

Dispatch priority: expects > per-operation stubs > fake > raise. Function fallback (stub/2), module fake, and stateful fake are mutually exclusive — setting one replaces the other.

Returns the contract module for piping.

fake(contract, fun, init_state)

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

fake(contract, module, seed, opts)

@spec fake(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.fake(&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, fun)

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

Add a stub for a contract operation or a stateless function fallback.

Per-operation stub

The function receives the argument list and returns the result. 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 function may be:

  • 1-arity fn [args] -> result end — stateless
  • 2-arity fn [args], state -> {result, new_state} end — stateful (requires fake/3 first)
  • 3-arity fn [args], state, all_states -> {result, new_state} end — cross-contract state access (requires fake/3 first)

Any arity may return Double.passthrough() to delegate to the fallback/fake for that specific call.

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

Function fallback (2-arity function)

When the function is 2-arity fn operation, args -> result end, it acts as a fallback for any operation on the contract that has no per-operation expect or stub. This is the same signature as set_fn_handler, so existing handler functions can be reused:

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

StubHandler module

A module implementing DoubleDown.Contract.Dispatch.StubHandler. The module's new/2 builds a dispatch function from an optional fallback:

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

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

For stateful fakes and module delegation, see fake/2 and fake/3.

Dispatch priority: expects > per-operation stubs > fallback/fake > raise. Function fallback, StubHandler, stateful fake, and module fake are mutually exclusive — setting one replaces the other.

Returns the contract module for piping.

stub(contract, module_or_operation, fun_or_fallback)

@spec stub(module(), atom(), function() | nil) :: module()

stub(contract, module, fallback_fn, opts)

@spec stub(module(), module(), (atom(), [term()] -> term()) | nil, keyword()) ::
  module()

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.