Skip to main content

docs/phoenix.md

# Phoenix Testing Without a Database

[< Testing](testing.md) | [Up: README](../README.md) | [Repo Test Doubles >](repo-doubles.md)

DoubleDown can power database-free Phoenix controller and LiveView tests
by combining `Phoenix.ConnTest` with in-memory Repo fakes. This gives
you the speed of unit tests with the integration coverage of ConnTests.

## UnitConnCase

The standard Phoenix `ConnCase` uses `Ecto.Adapters.SQL.Sandbox` for
database isolation. For database-free tests, create a `UnitConnCase`
that uses DoubleDown instead:

```elixir
# test/support/unit_conn_case.ex
defmodule MyAppWeb.UnitConnCase do
  use ExUnit.CaseTemplate

  using do
    quote do
      # Standard Phoenix.ConnTest imports
      import Plug.Conn
      import Phoenix.ConnTest
      import MyAppWeb.ConnCase, only: [build_conn: 0]

      alias MyAppWeb.Router.Helpers, as: Routes

      # The default endpoint for testing
      @endpoint MyAppWeb.Endpoint
    end
  end

  setup do
    # Install the in-memory Repo — every test gets a fresh store
    DoubleDown.Double.fallback(MyApp.Repo, DoubleDown.Repo.InMemory)
    :ok
  end
end
```

If your app uses `DynamicFacade` rather than `ContractFacade` for
the Repo, the setup is the same — `DynamicFacade.setup(MyApp.Repo)`
goes in `test_helper.exs`, and the `UnitConnCase` setup installs the
InMemory fallback:

```elixir
# test/test_helper.exs
DoubleDown.DynamicFacade.setup(MyApp.Repo)
{:ok, _} = DoubleDown.Testing.start()
ExUnit.start()

# test/support/unit_conn_case.ex — same as above
setup do
  DoubleDown.Double.fallback(MyApp.Repo, DoubleDown.Repo.InMemory)
  :ok
end
```

## Writing tests

Use ExMachina factories to build test data, then exercise the
endpoint:

```elixir
defmodule MyAppWeb.OrderControllerTest do
  use MyAppWeb.UnitConnCase

  import MyApp.Factory

  test "GET /orders lists the user's orders", %{conn: conn} do
    user = insert(:user)
    insert(:order, user_id: user.id, status: :active)
    insert(:order, user_id: user.id, status: :cancelled)

    conn = get(conn, "/orders?user_id=#{user.id}")

    assert json_response(conn, 200)["data"] |> length() == 2
  end

  test "POST /orders creates an order", %{conn: conn} do
    user = insert(:user)

    conn = post(conn, "/orders", %{user_id: user.id, item: "Widget"})

    assert %{"id" => _} = json_response(conn, 201)["data"]
  end
end
```

Writes (`insert`, `update`, `delete`) and PK reads (`get`, `get!`)
work out of the box with `Repo.InMemory`. Association preloading
also works.

## Handling Ecto.Query operations

`Repo.InMemory` can't evaluate `Ecto.Query` expressions — it only
handles bare schema queryables. If your controller calls domain
functions that run queries, you have several options:

### Option 1: Stub the query operation

Use a per-operation stub to intercept the specific Repo call:

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

test "GET /orders filters by status", %{conn: conn} do
  active_order = insert(:order, status: :active)

  # Stub :all to handle the query — InMemory handles everything else
  DoubleDown.Double.stub(MyApp.Repo, :all, fn
    [%Ecto.Query{}] -> [active_order]
    [schema] when is_atom(schema) -> DoubleDown.Double.passthrough()
  end)

  conn = get(conn, "/orders?status=active")

  assert json_response(conn, 200)["data"] |> length() == 1
end
```

### Option 2: Use InMemory with a fallback function

`Repo.InMemory` handles bare-schema reads authoritatively from its
in-memory store. For `Ecto.Query` operations it can't evaluate
natively, it delegates to the `fallback_fn`:

```elixir
setup do
  DoubleDown.Double.fallback(MyApp.Repo, DoubleDown.Repo.InMemory, [],
    fallback_fn: fn
      _contract, :all, [%Ecto.Query{from: %{source: {_, Order}}}], state ->
        state |> Map.get(Order, %{}) |> Map.values()

      _contract, operation, args, _state ->
        raise "Unhandled query: #{operation} #{inspect(args)}"
    end
  )
  :ok
end
```

### Option 3: DynamicFacade on application context modules

Most Phoenix apps already have context modules (`MyApp.Orders`,
`MyApp.Accounts`) that encapsulate Ecto queries. Use
`DynamicFacade.setup/1` on these modules and stub at the context
level — the Ecto queries are completely hidden behind the context
API:

```elixir
# test/test_helper.exs
DoubleDown.DynamicFacade.setup(MyApp.Orders)
DoubleDown.DynamicFacade.setup(MyApp.Accounts)
{:ok, _} = DoubleDown.Testing.start()
ExUnit.start()
```

Then in tests, stub the context functions directly:

```elixir
test "GET /orders lists active orders", %{conn: conn} do
  DoubleDown.Double.fallback(MyApp.Orders, fn
    _contract, :list_active_orders, [user_id] ->
      [%Order{id: 1, user_id: user_id, status: :active}]

    _contract, :get_order!, [id] ->
      %Order{id: id, status: :active}
  end)

  conn = get(conn, "/orders?user_id=42")

  assert json_response(conn, 200)["data"] |> length() == 1
end
```

This is often the cleanest approach for ConnTests because:

- **No Ecto.Query concerns at all** — the queries live inside the
  context module, and DynamicFacade intercepts at the function level
- **Tests match the controller's actual call pattern** — if the
  controller calls `Orders.list_active_orders(user_id)`, the test
  stubs exactly that
- **No new modules needed** — DynamicFacade works on your existing
  context modules
- **Tests that don't install a handler** get the real context
  implementation automatically

You can mix this with Repo-level InMemory for write operations:

```elixir
setup do
  # InMemory Repo for writes (insert, update, delete)
  DoubleDown.Double.fallback(MyApp.Repo, DoubleDown.Repo.InMemory)
  # Context-level stubs for query-heavy reads
  DoubleDown.Double.fallback(MyApp.Orders, fn _contract, op, args ->
    case {op, args} do
      {:list_active_orders, [_]} -> []
      {:count_orders, [_]} -> 0
    end
  end)
  :ok
end
```

### Option 4: Contract boundary above Repo

If your domain logic is behind a DoubleDown contract (e.g.
`MyApp.Orders` with `defcallback`), stub at that level instead of
at the Repo level:

```elixir
setup do
  DoubleDown.Double.fallback(MyApp.Orders, fn _contract, operation, args ->
    case {operation, args} do
      {:list_active_orders, [user_id]} ->
        [%Order{user_id: user_id, status: :active}]

      {:create_order, [params]} ->
        {:ok, struct!(Order, params)}
    end
  end)
  :ok
end
```

This is the cleanest approach — the test doesn't need to know about
Ecto queries at all. It stubs the domain contract and the controller
calls flow through naturally.

### Option 5: Expect specific calls

For tests that need to verify specific operations were called:

```elixir
test "POST /orders calls create_order exactly once", %{conn: conn} do
  user = insert(:user)

  DoubleDown.Double.expect(MyApp.Orders, :create_order, fn [params] ->
    assert params.user_id == user.id
    {:ok, struct!(Order, params)}
  end)

  post(conn, "/orders", %{user_id: user.id, item: "Widget"})

  DoubleDown.Double.verify!()
end
```

## Advanced scenarios

### Context fallback that reads Repo InMemory state

When a context module's stubbed function needs to return data
consistent with what's been inserted into the InMemory Repo (e.g.
via ExMachina factories), use a 5-arity stateful fallback with
cross-contract state access. The `all_states` snapshot includes
the Repo's InMemory store:

```elixir
setup do
  # InMemory Repo for writes
  DoubleDown.Double.fallback(MyApp.Repo, DoubleDown.Repo.InMemory)

  # Context fallback that reads the Repo's in-memory store
  DoubleDown.Double.fallback(
    MyApp.Orders,
    fn _contract, operation, args, _state, all_states ->
      repo_state = Map.get(all_states, MyApp.Repo, %{})
      orders = repo_state |> Map.get(Order, %{}) |> Map.values()

      result =
        case {operation, args} do
          {:list_active_orders, [user_id]} ->
            Enum.filter(orders, &(&1.user_id == user_id and &1.status == :active))

          {:count_orders, [user_id]} ->
            orders |> Enum.count(&(&1.user_id == user_id))

          {:get_order!, [id]} ->
            Enum.find(orders, &(&1.id == id)) || raise Ecto.NoResultsError
        end

      {result, _state}
    end,
    %{}
  )

  :ok
end

test "GET /orders returns factory-inserted orders", %{conn: conn} do
  user = insert(:user)
  insert(:order, user_id: user.id, status: :active)
  insert(:order, user_id: user.id, status: :cancelled)

  # The context fallback reads from the Repo InMemory store
  # and filters — no Ecto.Query needed
  conn = get(conn, "/orders?user_id=#{user.id}&status=active")

  assert json_response(conn, 200)["data"] |> length() == 1
end
```

The 5-arity handler receives `all_states` as a read-only snapshot
of every contract's state. See
[Cross-contract state access](testing.md#cross-contract-state-access)
for details.

### Context fallback that calls Repo via Defer

When a context fallback needs to *write* to the Repo (not just read
its state), use `Double.defer/1` to break out of the NimbleOwnership lock
and make re-entrant Repo calls:

```elixir
DoubleDown.Double.fallback(
  MyApp.Orders,
  fn
    _contract, :create_order, [params], state ->
      {DoubleDown.Double.defer(fn ->
        # Runs outside the lock — safe to call Repo
        changeset = Order.changeset(%Order{}, params)
        {:ok, order} = MyApp.Repo.insert(changeset)

        # Can also read back from Repo
        count = MyApp.Repo.aggregate(Order, :count)
        {:ok, order, count}
      end), state}

    _contract, :list_orders, [user_id], state ->
      {DoubleDown.Double.defer(fn ->
        # Read all orders from InMemory via Repo
        MyApp.Repo.all(Order)
        |> Enum.filter(&(&1.user_id == user_id))
      end), state}
  end,
  %{}
)
```

The deferred function runs after the context's state update is committed.
Its return value is what the caller receives. See
[Re-entrant dispatch via Defer](testing.md#re-entrant-dispatch-via-defer)
for details.

## When to use UnitConnCase vs ConnCase

| Aspect | `ConnCase` (DB) | `UnitConnCase` (DoubleDown) |
|--------|----------------|----------------------------|
| **Speed** | Slower (DB I/O) | Fast (in-memory) |
| **Ecto.Query** | Full support | Needs stubs for queries |
| **Read-after-write** | Full support | Full support (InMemory) |
| **Transactions** | Real ACID | In-memory with rollback |
| **ExMachina** | Works | Works |
| **Async** | Via Sandbox | Via NimbleOwnership |
| **Best for** | Integration tests, complex queries | Controller logic, JSON serialization, auth, error handling |

Use `UnitConnCase` when you're testing controller/LiveView logic
(parameter handling, authorization, response formatting) and the
domain operations can be stubbed. Use `ConnCase` when you need full
database fidelity (complex joins, constraint validation, migrations).

Many projects use both — `UnitConnCase` for the majority of endpoint
tests (fast feedback) and `ConnCase` for a smaller set of
integration tests that exercise the full stack.

---

[< Testing](testing.md) | [Up: README](../README.md) | [Repo Test Doubles >](repo-doubles.md)