Why do I need to manually checkout the connection when using a dynamic repo in tests?

Hi everyone,

I’m running into an issue when using a dynamic repository in my tests, and I’m trying to understand why the sandbox behaves differently compared to when I stick with a single statically configured repo.

Here’s a simplified example of my test module:

defmodule Hello.MyTest do
  use Hello.DataCase, async: true

  setup do
    Hello.Repo.start_link(
      name: :replica_db,
      database: "my_replica_test#{System.get_env("MIX_TEST_PARTITION")}"
    )

    Hello.Repo.put_dynamic_repo(:replica_db)

    Hello.Factory.create_users(10)
    :ok
  end

  test "should test something" do
    ...
  end
end

And my DataCase looks like this:

defmodule Hello.DataCase do
  use ExUnit.CaseTemplate

  using do
    quote do
      alias Hello.Repo

      import Ecto
      import Ecto.Changeset
      import Ecto.Query
      import Hello.DataCase
    end
  end

  setup tags do
    Hello.DataCase.setup_sandbox(tags)
    :ok
  end

  def setup_sandbox(tags) do
    pid = Ecto.Adapters.SQL.Sandbox.start_owner!(Hello.Repo, shared: not tags[:async])
    on_exit(fn -> Ecto.Adapters.SQL.Sandbox.stop_owner(pid) end)
  end

  def errors_on(changeset), do: ...
end

My test_helper.exs is also pretty standard:

ExUnit.start()
Ecto.Adapters.SQL.Sandbox.mode(Hello.Repo, :manual)

When I run this with a single, static repo, I don’t have to do anything extra — the sandbox is automatically set up and all my changes are rolled back at the end of the test.

But when I switch to using a dynamic repo (via put_dynamic_repo/1), I suddenly need to explicitly do:

Ecto.Adapters.SQL.Sandbox.mode(Hello.Repo, :manual)
Ecto.Adapters.SQL.Sandbox.checkout(Hello.Repo)

Otherwise, every change I made to the db is committed (eg. the users created by my factory are still there at the end of the test).


My question is:
Why does using a dynamic repo make the sandbox behave differently?
Is there a way to configure the dynamic repo so that I don’t need to manually set the mode and checkout?

Thanks in advance!


edit:

I read this documentation page, but it didn’t help me solve my issue.

Also, it seems like I could define more than one repo in my application’s supervision tree like this:

defmodule Hello.Application do
  use Application

  @impl true
  def start(_type, _args) do
    children = [
      # Start the Ecto repository
      Hello.Repo,
      {Hello.Repo, name: :replica_db}
    ]

    Supervisor.start_link(children, [strategy: :one_for_one, name: Hello.Supervisor])
  end
end

However, this raises the following error:

** (Mix) Could not start application hello: Hello.Application.start(:normal, []) returned an error: bad child specification, more than one child specification has the id: Hello.Repo.

With the default sandbox behavour you’re already within the context of a checked out connection as well as the sandbox transaction in that setup block. You’d probably need to handle the dynamic repo before the start_owner! call.

Thank you, @LostKobrakai.

I tried this:

  setup tags do
    Hello.Repo.start_link(
      name: :replica_db,
      database: "my_replica_test#{System.get_env("MIX_TEST_PARTITION")}"
    )

    Hello.Repo.put_dynamic_repo(:replica_db)

    Hello.DataCase.setup_sandbox(tags)

    Hello.Factory.create_users(10)
    :ok
  end

And I removed the following function from Hello.DataCase

  setup tags do
    Hello.DataCase.setup_sandbox(tags)
    :ok
  end

But the transactions are still not being rolled back.

Looking at the implemenation of start_owner! it might simply not support dynamic repos. You might need to resort to the older checkout api.

start_owner! lets an external process hold the reference to the connection and that external process has no idea about your dynamic repo. start_owner! on the other hand will keep the sandbox around after the test’s process stops with the end of the testcase and therefore needs to be a separate process to the test process.

If that’s the case, then the documentation is wrong and should be updated.

However, looking at the code, it seems that it calls the checkout function with :ok = checkout(repo, opts):

  def start_owner!(repo, opts \\ []) do
    parent = self()

    {:ok, pid} =
      Agent.start(fn ->
        {shared, opts} = Keyword.pop(opts, :shared, false)
        :ok = checkout(repo, opts)

        if shared do
          :ok = mode(repo, {:shared, self()})
        else
          :ok = allow(repo, self(), parent)
        end
      end)

    pid
  end

That’s what I’m doing in my setup, so I’m a bit confused.

That’s in a different process though. put_dynamic_repo only applies to the current process you’re in. It doesn’t affect any other processes.

Right! This made me realize that I can pass the dynamic repo in my setup, but some changes are needed in the Ecto.Adapters.SQL.Sandbox module, so I opened this PR.

I tested it locally, and it seems to work as long as setup_sandbox is called after setting a dynamic repo.