Test Hex.pm Documentation

Contract boundaries and test doubles for Elixir. Define a contract (the interface), generate a dispatch facade (what callers use), and swap implementations at test time — with stateful fakes powerful enough to test Ecto.Repo operations without a database.

Why DoubleDown?

DoubleDown extends Jose Valim's Mocks and explicit contracts pattern:

  • Zero boilerplatedefcallback generates the @behaviour, @callback, dispatch facade, and @spec from a single declaration. Or generate a facade from an existing @behaviour module, or use Mimic-style bytecode interception for any module. See Choosing a facade type.
  • Zero-cost production dispatch — facades compile to inlined direct calls. MyContract.do_thing(args) produces identical bytecode to DirectImpl.do_thing(args) — the facade disappears entirely after BEAM inlining. Contract boundaries have no runtime cost.
  • When mocks are not enough: stateful fakes — in-memory state with atomic updates, read-after-write consistency, Ecto.Multi transactions, and rollback. Fast enough for property-based testing.
  • ExMachina factory integrationRepo.InMemory works as a drop-in replacement for the Ecto sandbox. Factory-inserted records are readable via all, get_by, aggregate — no database, no sandbox, async: true.
  • Fakes with expectations — layer expects over a stateful fake to simulate failures. First insert writes to the in-memory store, second returns a constraint error, subsequent reads find the first record.
  • Dispatch logging — logs the full {contract, operation, args, result} tuple for every call. DoubleDown.Log provides structured pattern matching over those logs.

What DoubleDown provides

Contracts and dispatch

FeatureDescription
defcallback contractsTyped signatures with parameter names, @doc sync, pre-dispatch transforms
Vanilla behaviour facadesBehaviourFacade — dispatch facade from any existing @behaviour module
Dynamic facadesDynamicFacade — Mimic-style bytecode shim, module becomes ad-hoc contract
Zero-cost static dispatchInlined direct calls in production — no overhead vs calling the impl directly
Generated @spec + @docLSP-friendly on defcallback and BehaviourFacade facades
Standard @behaviourAll contracts are Mox-compatible — @behaviour + @callback

Test doubles

FeatureDescription
Mox-style expect/stubDoubleDown.Double — ordered expectations, call counting, verify!
Stateful fakesIn-memory state with atomic updates via NimbleOwnership
Expect + fake compositionLayer expects over a stateful fake for failure simulation
:passthrough expectsCount calls without changing behaviour
Transaction rollbackrollback/1 restores pre-transaction state in InMemory fakes
Dispatch loggingRecord {contract, op, args, result} for every call
Structured log matchingDoubleDown.Log — pattern-match on logged results
Async-safeProcess-scoped isolation via NimbleOwnership, async: true out of the box

Built-in Ecto Repo

Full Ecto.Repo contract (DoubleDown.Repo) with three test doubles:

DoubleTypeBest for
Repo.StubStateless stubFire-and-forget writes, canned read responses
Repo.InMemoryClosed-world fakeFull in-memory store; all bare-schema reads; ExMachina factories
Repo.OpenInMemoryOpen-world fakePK-based read-after-write; fallback for other reads

All three support Ecto.Multi transactions with rollback, PK autogeneration, changeset validation, timestamps, and both changeset and bare struct inserts. See Repo.

Quick example

This example uses defcallback contracts — the recommended approach for new code. For existing @behaviour modules, see DoubleDown.BehaviourFacade. For Mimic-style interception of any module, see DoubleDown.DynamicFacade.

Define contracts

Use the built-in DoubleDown.Repo contract for database operations, and define domain-specific contracts for business logic:

# Repo facade — wraps your Ecto Repo
defmodule MyApp.Repo do
  use DoubleDown.ContractFacade, contract: DoubleDown.Repo, otp_app: :my_app
end

# Domain model contract — queries specific to your domain
defmodule MyApp.Todos.Model do
  use DoubleDown.ContractFacade, otp_app: :my_app

  defcallback active_todos(tenant_id :: String.t()) :: [Todo.t()]
  defcallback todo_exists?(tenant_id :: String.t(), title :: String.t()) :: boolean()
end

Write orchestration code

The context module orchestrates domain logic using both contracts — Repo for writes, Model for domain queries:

defmodule MyApp.Todos do
  def create(tenant_id, params) do
    if MyApp.Todos.Model.todo_exists?(tenant_id, params.title) do
      {:error, :duplicate}
    else
      MyApp.Repo.insert(Todo.changeset(%Todo{tenant_id: tenant_id}, params))
    end
  end
end

Wire up production implementations

# config/config.exs
config :my_app, DoubleDown.Repo, impl: MyApp.EctoRepo
config :my_app, MyApp.Todos.Model, impl: MyApp.Todos.Model.Ecto

Define a factory

Point ExMachina at your Repo facade (not your Ecto Repo):

defmodule MyApp.Factory do
  use ExMachina.Ecto, repo: MyApp.Repo

  def todo_factory do
    %Todo{
      tenant_id: "t1",
      title: sequence(:title, &"Todo #{&1}")
    }
  end
end

Test without a database

Start the ownership server in test/test_helper.exs:

DoubleDown.Testing.start()

Test the orchestration with fakes and factories — no database, full async isolation:

defmodule MyApp.TodosTest do
  use ExUnit.Case, async: true
  import MyApp.Factory

  setup do
    # InMemory Repo — factory inserts land here
    DoubleDown.Double.fake(DoubleDown.Repo, DoubleDown.Repo.InMemory)

    # Domain model queries reading from the Repo's InMemory store
    # via cross-contract state access (4-arity fake)
    DoubleDown.Double.fake(MyApp.Todos.Model,
      fn operation, args, state, all_states ->
        repo = Map.get(all_states, DoubleDown.Repo, %{})
        todos = repo |> Map.get(Todo, %{}) |> Map.values()

        result =
          case {operation, args} do
            {:active_todos, [tenant]} ->
              Enum.filter(todos, &(&1.tenant_id == tenant))

            {:todo_exists?, [tenant, title]} ->
              Enum.any?(todos, &(&1.tenant_id == tenant and &1.title == title))
          end

        {result, state}
      end,
      %{}
    )

    :ok
  end

  test "creates a todo when no duplicate exists" do
    assert {:ok, todo} = MyApp.Todos.create("t1", %{title: "Ship it"})
    assert todo.tenant_id == "t1"

    # Read-after-write: InMemory serves from store
    assert ^todo = MyApp.Repo.get(Todo, todo.id)
  end

  test "rejects duplicate todos" do
    # Factory insert lands in InMemory store
    insert(:todo, tenant_id: "t1", title: "Ship it")

    # Model.todo_exists? reads from InMemory store, finds the duplicate
    assert {:error, :duplicate} = MyApp.Todos.create("t1", %{title: "Ship it"})
  end

  test "handles constraint violation on insert" do
    # First insert fails with constraint error
    DoubleDown.Double.expect(DoubleDown.Repo, :insert, fn [changeset] ->
      {:error, Ecto.Changeset.add_error(changeset, :title, "taken")}
    end)

    assert {:error, cs} = MyApp.Todos.create("t1", %{title: "Conflict"})
    assert {"taken", _} = cs.errors[:title]

    # Second call succeeds — expect consumed, InMemory handles it
    assert {:ok, _} = MyApp.Todos.create("t1", %{title: "Conflict"})
  end
end

Documentation

  • Getting Started — contracts, facades, dispatch resolution, terminology
  • Testing — Double expect/stub/fake, stateful responders, cross-contract state access
  • Dynamic Facades — Mimic-style bytecode interception, fake any module without an explicit contract
  • Logging — dispatch logging, Log matchers, structured log assertions
  • Process Sharing — async safety, allow, global mode, supervision tree testing
  • Repo — built-in Ecto Repo contract and production config
  • Repo Test DoublesRepo.Stub, Repo.InMemory, Repo.OpenInMemory, ExMachina integration
  • Repo Testing Patterns — failure simulation, transactions, rollback, cross-contract state
  • Migration — incremental adoption, coexisting with direct Ecto.Repo calls

Installation

Add double_down to your dependencies in mix.exs:

def deps do
  [
    {:double_down, "~> 0.25"}
  ]
end

Ecto is an optional dependency — add it to your own deps if you want the built-in Repo contract.

Relationship to Skuld

DoubleDown extracts the contract and test double system from Skuld (algebraic effects for Elixir) into a standalone library. You get typed contracts, async-safe test doubles, and dispatch logging without needing Skuld's effect system. Skuld depends on DoubleDown and layers effectful dispatch on top.

License

MIT License - see LICENSE for details.