< Double API | Up: Guides | Index | Changelog >

DoubleDown ships a complete Ecto.Repo contract and three test doubles, letting you replace the database with an in-memory store in tests while keeping your existing ExMachina factories and Ecto.Multi transactions.

The contract

DoubleDown.Repo defines the full Ecto.Repo surface — writes, reads, aggregates, associations, streaming, and transactions:

CategoryOperations
Writesinsert, update, delete, insert_or_update + bang variants
Bulkinsert_all, update_all, delete_all
PK readsget, get!
Non-PK readsget_by, get_by!, one, one!, all, all_by, exists?, aggregate
Associationspreload, load, reload, reload!
Streamingstream
Transactionstransact, rollback, in_transaction?
Raw SQLquery, query!

Creating a Repo facade

defmodule MyApp.Repo do
  use DoubleDown.ContractFacade, contract: DoubleDown.Repo, otp_app: :my_app
end
# config/config.exs
config :my_app, DoubleDown.Repo, impl: MyApp.EctoRepo

With the default :static_dispatch? setting, production dispatch compiles away entirely — MyApp.Repo.insert(changeset) produces identical bytecode to MyApp.EctoRepo.insert(changeset).

With DynamicFacade

If your Repo module has custom functions beyond the standard API, or you don't want a facade module:

# test/test_helper.exs
DoubleDown.DynamicFacade.setup(MyApp.EctoRepo)
ExUnit.start()

Then in tests, use your Ecto Repo module directly as the contract:

DoubleDown.Double.fallback(MyApp.EctoRepo, DoubleDown.Repo.InMemory)

Test doubles

DoubleTypeReadsBest for
Repo.InMemoryClosed-world fakeAll bare-schema readsExMachina, most tests
Repo.OpenInMemoryOpen-world fakePK reads onlyFine-grained fallback control
Repo.StatelessStateless stubFallback onlyFire-and-forget writes

All three validate changesets (returning {:error, changeset} on invalid), autogenerate primary keys and timestamps via Ecto schema metadata, accept both changesets and bare structs, and support Ecto.Multi transactions.

Closed-world semantics — the in-memory store is the complete truth. If a record isn't in the store, it doesn't exist. All bare-schema reads (get, get_by, all, exists?, aggregate) work without a fallback.

setup do
  DoubleDown.Double.fallback(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)
end

Seed data can be passed as the third argument:

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

Ecto.Query operations that InMemory can't evaluate natively delegate to an optional fallback_fn:

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

Repo.OpenInMemory

Open-world semantics — the store may be incomplete. PK reads hit the store first then fall through to a user-supplied fallback. All other reads always go through the fallback. Use when you need fine-grained control over which reads are served from state:

DoubleDown.Double.fallback(DoubleDown.Repo, DoubleDown.Repo.OpenInMemory, [],
  fallback_fn: fn
    _contract, :get_by, [User, [email: email]], _state -> %User{email: email}
    _contract, :all, [User], state -> state |> Map.get(User, %{}) |> Map.values()
  end
)

Repo.Stateless

Writes succeed but store nothing. Reads raise unless you supply a fallback function. Best for simple command-style functions:

DoubleDown.Double.fallback(DoubleDown.Repo, DoubleDown.Repo.Stateless)
DoubleDown.Double.expect(DoubleDown.Repo, :insert, fn [changeset] ->
  {:error, Ecto.Changeset.add_error(changeset, :email, "taken")}
end)

ExMachina integration

Point ExMachina at your Repo facade (not your Ecto Repo - unless you used a DynamicFacade, in which case your Repo facade is your Ecto Repo):

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

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

With Repo.InMemory installed, factory inserts land in the in-memory store and all bare-schema reads find them:

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

test "factory-inserted records are readable" do
  insert(:user, name: "Alice")
  insert(:user, name: "Bob")

  assert [_, _] = MyApp.Repo.all(User)
  assert %User{name: "Alice"} = MyApp.Repo.get_by(User, name: "Alice")
  assert 2 = MyApp.Repo.aggregate(User, :count, :id)
end

Transactions and rollback

transact/2 mirrors Ecto.Repo.transact/2 — accepts a function or an Ecto.Multi:

# Function
MyApp.Repo.transact(fn ->
  {:ok, user} = MyApp.Repo.insert(user_changeset)
  {:ok, profile} = MyApp.Repo.insert(profile_changeset(user))
  {:ok, {user, profile}}
end, [])

# Ecto.Multi
Ecto.Multi.new()
|> Ecto.Multi.insert(:user, user_changeset)
|> Ecto.Multi.run(:profile, fn repo, %{user: user} ->
  repo.insert(profile_changeset(user))
end)
|> MyApp.Repo.transact([])

rollback/1 aborts the transaction and restores the pre-transaction state in Repo.InMemory and Repo.OpenInMemory:

MyApp.Repo.transact(fn repo ->
  {:ok, _} = repo.insert(user_changeset)
  if problem?, do: repo.rollback(:constraint_violated)
  {:ok, user}
end, [])
# Returns {:error, :constraint_violated}; insert is undone

Only the Repo contract's state is restored on rollback — other contracts' state modified during the transaction is unaffected. The in-memory adapters do not provide full ACID isolation (sub-operations are individually atomic but not grouped). For true ACID, use the real Ecto adapter in integration tests.

What stays on the database

Tests that exercise database-specific behaviour should continue to run against a real database:

  • Query correctnessEcto.Query expressions can't be fully evaluated in memory
  • Constraint validation — unique indexes, foreign keys, check constraints
  • Transaction isolation — concurrent writes, rollback interaction
  • Migrations — schema changes

The goal isn't to eliminate DB tests — it's to move the ~3/4 of tests that use the database merely as a slow data-fixture mechanism to run DB-free.


< Double API | Up: Guides | Index | Changelog >