How LiveView got rid of dangling processes in tests – and how we can do the same

Back in LiveView 0.12, testing a LiveView connected to the database would sometimes result in our test passing but us seeing the Dreaded Wall of Red Text.

Tests pass but we still see a large error message from Ecto: ** (DBConnection.OwnershipError) cannot find ownership process for #PID<0.467.0>. When using ownership, you must manage connections in one of the four ways: * By explicitly checking out a connection * By explicitly allowing a spawned process * By running the pool in shared mode * By using :caller option with allowed process. See Ecto.Adapters.SQL.Sandbox docs for more information.

Then, in LiveView 0.13.0, those errors were gone. How?

The problem: LiveView processes outliving tests

Before LiveView 0.13.0, the autogenerated ConnCase looked like this:

setup tags do
  :ok = Ecto.Adapters.SQL.Sandbox.checkout(MyApp.Repo)

  unless tags[:async] do
    Ecto.Adapters.SQL.Sandbox.mode(MyApp.Repo, {:shared, self()})
  end

  {:ok, conn: Phoenix.ConnTest.build_conn()}
end

Our test process would check out a database connection via Sandbox.checkout/2, making it the connection owner. If we ran our tests with async: false, we would set the Sandbox mode to :shared – meaning that other processes would use the same database connection.

Meanwhile, the LiveView process started with live/2 would be under a LiveView supervisor separate from the tests. That meant it could outlive the test process that started it.

So, when our assertion was satisfied, the test process terminated. As the owner, it took the database connection with it. But the LiveView process was still trying to complete work with the database.

In came the red wall of text.

How Wojtek Mach fixed it by making contributions to 3 repos

To solve the problem, Wojtek Mach made it so that the LiveView process terminates before the database connection is taken away. He accomplished that by doing two things:

  1. Decoupling the test process from the database connection
  2. Ensuring the LiveView process terminates with the test

First, Wojtek separated the owner of the database connection from the test process by introducing the Sandbox.start_owner!/2 and Sandbox.stop_owner/1 functions.

With start_owner!/2, a process detached from the test process checks out the database connection. The owner then allows the test process access to the connection.

Since the owner is not linked to the test, we need to stop it manually with Sandbox.stop_owner/1. We can do that conveniently through ExUnit’s on_exit/2 callback – guaranteeing that we stop the connection after the test runs.

Then, Wojtek updated the autogenerated Phoenix ConnCase to do just that:

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

  {:ok, conn: Phoenix.ConnTest.build_conn()}
end

Thus, the test process is allowed access to the database but is not its owner. And clean up is guaranteed to run after the test ends. And just as with the previous implementation, passing async: false sets the Sandbox mode to :shared.

Finally, Wojtek updated LiveView to start the LiveView process under the test supervisor instead of the LiveView supervisor.

Unlike the LiveView supervisor, the test supervisor cleans up its child processes at the end of the test but before on_exit/2 runs.

So, when the test finishes, the test supervisor terminates the LiveView process, and then, and only then, does the on_exit/2 callback run Sandbox.stop_owner/1, closing the database connection.

Beautiful simplicity ✨ – all thanks to Wojtek’s efforts.

How we can do the same in our tests

Having seen the techniques applied to LiveView, we can do the same with our tests by doing two things.

First, we need to make sure that our test process doesn’t own the database connection. We can take a page from the new ConnCase setup and update our test setup to look something like this:

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

  :ok
end

Second, we need to start any processes that could outlive the test under the test supervisor. We can do that with ExUnit’s start_supervised/2 function:

  test "can push and pop things from stack", %{test: test_name} do
    {:ok, stack} = start_supervised({Stack, [name: test_name]})

    :ok = Stack.push(stack, 23)
    :ok = Stack.push(stack, 56)

    assert 56 == Stack.pop(stack)
  end

Doing those two things will ensure our supervised process will terminate before the database connection is taken away.

Want my latest thoughts, posts, and projects in your inbox?

    I will never send you spam. Unsubscribe any time.