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.
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:
- Decoupling the test process from the database connection
- 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.