DoubleDown

TestHex.pmDocumentation

Builds on the Mox pattern — generates behaviours and dispatch facades from defcallback declarations — and adds stateful test doubles powerful enough to test Ecto.Repo operations without a database.

Why DoubleDown?

DoubleDown extends the Mox pattern:

What DoubleDown provides

System boundaries (the Mox pattern, automated)

Feature Description
defcallback declarations Typed function signatures with parameter names and return types
Contract behaviour generation Standard @behaviour + @callback — fully Mox-compatible
Dispatch facades Config-dispatched caller functions, generated automatically
LSP-friendly @doc and @spec on every generated function

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
Built-in Ecto Repo 17-operation contract with Repo.Test and Repo.InMemory fakes
Async-safe Process-scoped isolation via NimbleOwnership, async: true out of the box

Quick example

Define a contract behaviour and dispatch facade in one module:

defmodule MyApp.Todos do
  use DoubleDown.Facade, otp_app: :my_app

  defcallback create_todo(params :: map()) ::
    {:ok, Todo.t()} | {:error, Ecto.Changeset.t()}

  defcallback get_todo(id :: String.t()) ::
    {:ok, Todo.t()} | {:error, :not_found}

  defcallback list_todos(tenant_id :: String.t()) :: [Todo.t()]
end

Implement the behaviour:

defmodule MyApp.Todos.Ecto do
  @behaviour MyApp.Todos

  @impl true
  def create_todo(params), do: MyApp.Repo.insert(Todo.changeset(params))

  @impl true
  def get_todo(id) do
    case MyApp.Repo.get(Todo, id) do
      nil -> {:error, :not_found}
      todo -> {:ok, todo}
    end
  end

  @impl true
  def list_todos(tenant_id) do
    MyApp.Repo.all(from t in Todo, where: t.tenant_id == ^tenant_id)
  end
end

Wire it up:

# config/config.exs
config :my_app, MyApp.Todos, impl: MyApp.Todos.Ecto

Start the test ownership server in test/test_helper.exs:

DoubleDown.Testing.start()

Test with expects and stubs — no database, full async isolation:

setup do
  MyApp.Todos
  |> DoubleDown.Double.expect(:create_todo, fn [params] ->
    {:ok, struct!(Todo, Map.put(params, :id, "123"))}
  end)
  |> DoubleDown.Double.stub(:get_todo, fn [id] -> {:ok, %Todo{id: id}} end)
  |> DoubleDown.Double.stub(:list_todos, fn [_] -> [] end)
  :ok
end

test "create then get" do
  {:ok, todo} = MyApp.Todos.create_todo(%{title: "Ship it"})
  assert {:ok, _} = MyApp.Todos.get_todo(todo.id)
  DoubleDown.Double.verify!()
end

Testing failure scenarios

Layer expects over a stateful fake to simulate specific failures:

setup do
  # InMemory Repo as the baseline — real state, read-after-write
  DoubleDown.Repo
  |> DoubleDown.Double.fake(&DoubleDown.Repo.InMemory.dispatch/3,
    DoubleDown.Repo.InMemory.new())
  # First insert fails with constraint error
  |> DoubleDown.Double.expect(:insert, fn [changeset] ->
    {:error, Ecto.Changeset.add_error(changeset, :email, "taken")}
  end)
  :ok
end

test "retries after constraint violation" do
  changeset = User.changeset(%User{}, %{email: "alice@example.com"})

  # First insert: expect fires, returns error
  assert {:error, _} = MyApp.Repo.insert(changeset)

  # Second insert: falls through to InMemory, writes to store
  assert {:ok, user} = MyApp.Repo.insert(changeset)

  # Read-after-write: InMemory serves from store
  assert ^user = MyApp.Repo.get(User, user.id)
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.