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 (beyond Mox)

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
Stubs and fakes as fallbacks Dispatch priority chain: expects > stubs > fake > raise
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 fakes

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

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

All three support Ecto.Multi transactions, PK autogeneration, changeset validation, and timestamps. 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

Test without a database

Start the ownership server in test/test_helper.exs:

DoubleDown.Testing.start()

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

setup do
  # InMemory Repo for writes — read-after-write consistency
  DoubleDown.Double.fake(DoubleDown.Repo, DoubleDown.Repo.InMemory)

  # Domain model queries read 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
  # First create succeeds — record lands in InMemory store
  assert {:ok, _} = MyApp.Todos.create("t1", %{title: "Ship it"})

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

Testing failure scenarios

Layer expects over the InMemory Repo to simulate database failures:

setup do
  DoubleDown.Double.fake(DoubleDown.Repo, DoubleDown.Repo.InMemory)
  DoubleDown.Double.stub(MyApp.Todos.Model, fn :todo_exists?, [_, _] -> false end)
  :ok
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

Documentation

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.