DoubleDown

TestHex.pmDocumentation

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:

What DoubleDown provides

Contracts and dispatch

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

Test doubles

Feature Description
Mox-style expect/stub DoubleDown.Double — ordered expectations, call counting, verify!
Stateful fakes In-memory state with atomic updates via NimbleOwnership
Expect + fake composition Layer expects over a stateful fake for failure simulation
:passthrough expects Count calls without changing behaviour
Transaction rollback rollback/1 restores pre-transaction state in InMemory fakes
Dispatch logging Record {contract, op, args, result} for every call
Structured log matching DoubleDown.Log — pattern-match on logged results
Async-safe Process-scoped isolation via NimbleOwnership, async: true out of the box

Built-in Ecto Repo

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

Double Type Best for
Repo.Stub Stateless stub Fire-and-forget writes, canned read responses
Repo.InMemory Closed-world fake Full in-memory store; all bare-schema reads; ExMachina factories
Repo.OpenInMemory Open-world fake PK-based read-after-write; fallback for other reads

All three support Ecto.Multi transactions with rollback, PK autogeneration, changeset validation, timestamps, insert_or_update, preload, reload, stream (via fallback), 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

Installation

Add double_down to your dependencies in mix.exs:

def deps do
  [
    {:double_down, "~> 0.48"}
  ]
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.