< Repo | Up: README | Repo Testing Patterns >

DoubleDown ships three test doubles for the DoubleDown.Repo contract. Each is designed for a different testing scenario:

DoubleTypeStateBest for
Repo.StubStateless stubNoneFire-and-forget writes, canned read responses
Repo.InMemoryClosed-world fake%{Schema => %{pk => struct}}Full in-memory store; ExMachina factories; all bare-schema reads
Repo.OpenInMemoryOpen-world fake%{Schema => %{pk => struct}}PK-based read-after-write; fallback for other reads

Shared behaviour

All three test doubles share these behaviours for write operations:

  • Changeset validation — if changeset.valid? is false, the operation returns {:error, changeset} without side effects, matching real Ecto Repo behaviour.
  • Bare struct insertsinsert/insert! accept both Ecto.Changeset and bare structs (matching Ecto.Repo).
  • Primary key autogeneration:id (auto-increment), :binary_id (UUID), parameterized types (Ecto.UUID, Uniq.UUID etc.), @primary_key false, and autogenerate: false are all handled via Ecto schema metadata. Explicitly set PK values are preserved.
  • Timestampsinserted_at/updated_at are auto-populated on insert and refreshed on update via __schema__(:autogenerate). Custom field names and types are handled automatically. Explicitly set timestamps are preserved.

The stateful fakes (InMemory and OpenInMemory) also support seed data — pre-populate the store by passing a list of structs as the third argument to Double.fake:

DoubleDown.Double.fake(DoubleDown.Repo, DoubleDown.Repo.InMemory,
  [%User{id: 1, name: "Alice"}, %Item{id: 1, sku: "widget"}])

Repo.Stub

A fire-and-forget adapter. Write operations apply changeset changes and return {:ok, struct}, but nothing is stored. Read operations delegate to an optional fallback function, or raise with an actionable error message.

Repo.Stub implements DoubleDown.Contract.Dispatch.StubHandler and can be used by module name with Double.stub:

# Writes only — reads will raise with a suggestion:
DoubleDown.Double.stub(DoubleDown.Repo, DoubleDown.Repo.Stub)

# With fallback for reads:
DoubleDown.Double.stub(DoubleDown.Repo, DoubleDown.Repo.Stub,
  fn
    :get, [User, 1] -> %User{id: 1, name: "Alice"}
    :all, [User] -> [%User{id: 1, name: "Alice"}]
    :exists?, [User] -> true
  end
)

Use Repo.Stub when your test only needs fire-and-forget writes and a few canned read responses. For read-after-write consistency, use Repo.InMemory.

Repo.InMemory uses closed-world semantics: the state is the complete truth. If a record isn't in the state, it doesn't exist. This makes the adapter authoritative for all bare schema operations without needing a fallback — the fallback becomes the escape hatch for Ecto.Query queryables, not the default path.

This is the recommended Repo fake for most tests.

CategoryOperationsBehaviour
Writesinsert, update, deleteStore in state
PK readsget, get!Return nil / raise on miss (no fallback)
Clause readsget_by, get_by!Scan and filter all records
Collection readsall, one/one!, exists?Scan all records of schema
AggregatesaggregateCompute from records in state
Bulk writesinsert_all, delete_all, update_all (set:)Modify state directly
Transactionstransact, rollbackDelegate to sub-operations; rollback restores state
Ecto.QueryAny operation with Ecto.Query queryableFallback or error

Basic usage

setup do
  DoubleDown.Double.fake(DoubleDown.Repo, DoubleDown.Repo.InMemory)
  :ok
end

test "insert then read back" do
  {:ok, user} = MyApp.Repo.insert(User.changeset(%{name: "Alice"}))
  assert ^user = MyApp.Repo.get(User, user.id)
  assert [^user] = MyApp.Repo.all(User)
  assert %User{} = MyApp.Repo.get_by(User, name: "Alice")
end

Ecto.Query fallback

The fallback function is available as an escape hatch for Ecto.Query queryables that cannot be evaluated in-memory:

DoubleDown.Double.fake(
  DoubleDown.Repo,
  DoubleDown.Repo.InMemory,
  [],
  fallback_fn: fn
    :all, [%Ecto.Query{}], _state -> []
  end
)

ExMachina integration

Repo.InMemory works with ExMachina factories as a drop-in replacement for the Ecto sandbox. Factory insert calls go through the Repo facade dispatch, land in the InMemory store, and all subsequent bare-schema reads work — all, get_by, aggregate, etc. (Ecto.Query reads still need a fallback — see Ecto.Query fallback above.) No database, no sandbox, async: true, at speeds suitable for property-based testing.

Step 1: Define your factory

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

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

  def user_factory do
    %MyApp.User{
      name: sequence(:name, &"User #{&1}"),
      email: sequence(:email, &"user#{&1}@example.com"),
      age: 25
    }
  end
end

Step 2: Set up InMemory in your test

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

  setup do
    DoubleDown.Double.fake(DoubleDown.Repo, DoubleDown.Repo.InMemory)
    :ok
  end

  test "factory-inserted records are readable" do
    insert(:user, name: "Alice", email: "alice@example.com")
    insert(:user, name: "Bob", email: "bob@example.com")

    # All bare-schema reads work — no fallback needed
    assert [_, _] = MyApp.Repo.all(User)
    assert %User{name: "Alice"} = MyApp.Repo.get_by(User, email: "alice@example.com")
    assert 2 = MyApp.Repo.aggregate(User, :count, :id)
  end

  test "read-after-write consistency" do
    user = insert(:user, name: "Alice")
    assert ^user = MyApp.Repo.get(User, user.id)
  end

  test "failure simulation over factory data" do
    insert(:user, name: "Alice")
    insert(:user, name: "Bob")

    # Intercept the next insert! to simulate a constraint error
    DoubleDown.Double.expect(DoubleDown.Repo, :insert!, fn [struct] ->
      cs = Ecto.Changeset.change(struct) |> Ecto.Changeset.add_error(:name, "taken")
      raise Ecto.InvalidChangesetError, action: :insert, changeset: cs
    end)

    assert_raise Ecto.InvalidChangesetError, fn ->
      insert(:user, name: "Carol")
    end

    # Existing records are unaffected
    assert 2 = MyApp.Repo.aggregate(User, :count, :id)
  end
end

This gives you a similar developer experience to the Ecto sandbox — factories write records, reads find them — but without a database process, without sandbox checkout, and at pure-function speed.

Repo.InMemory is not trying to replace the database for tests that exercise database-specific behaviour — query correctness, constraint validation, transaction isolation, index performance. It replaces the database for tests that are using the database as a slow but convenient way to get test data to the right place at the right time. If your test's purpose is "verify that the orchestration logic does the right thing given these inputs", InMemory handles the data plumbing so the test can focus on the logic.

For a complete working example, see test/double_down/repo/ex_machina_test.exs in the DoubleDown source.

Repo.OpenInMemory

Repo.OpenInMemory uses open-world semantics: the state may be incomplete. When a record is not found, the adapter falls through to a user-supplied fallback function rather than returning nil. Use this when you need fine-grained control over which reads come from state vs fallback.

For most tests, prefer Repo.InMemory (closed-world) which handles all bare-schema reads without a fallback.

CategoryOperationsBehaviour
Writesinsert, update, deleteStore in state
PK readsget, get!State first, then fallback
get_byget_by, get_by!PK lookup when PK in clauses, then fallback
Other readsone, all, exists?, aggregateAlways fallback
Bulkinsert_all, update_all, delete_allAlways fallback
Transactionstransact, rollbackDelegate to sub-operations; rollback restores state

Basic usage — writes and PK reads

If your test only needs writes and PK-based lookups, no fallback is needed:

setup do
  DoubleDown.Double.fake(DoubleDown.Repo, DoubleDown.Repo.OpenInMemory)
  :ok
end

test "insert then get by PK" do
  {:ok, user} = MyApp.Repo.insert(User.changeset(%{name: "Alice"}))
  assert ^user = MyApp.Repo.get(User, user.id)
end

Fallback function for non-PK reads

For operations the state cannot answer, supply a fallback_fn. The fallback receives (operation, args, state) where state is the clean store map (internal keys stripped):

DoubleDown.Double.fake(
  DoubleDown.Repo,
  DoubleDown.Repo.OpenInMemory,
  [%User{id: 1, name: "Alice", email: "alice@example.com"}],
  fallback_fn: fn
    :get_by, [User, [email: email]], _state -> %User{id: 1, email: email}
    :all, [User], state -> state |> Map.get(User, %{}) |> Map.values()
  end
)

Error on unhandled operations

When an operation can't be served by either state or fallback, Repo.OpenInMemory raises ArgumentError with a message showing the exact operation and suggesting how to add a fallback clause.


< Repo | Up: README | Repo Testing Patterns >