Running a LiveView test with `async: true`

I wanted to run some LiveTests with

use MyApp.ConnCase, async: true

but I was having a problem because, in async mode, each process gets its own Sandbox connection.

In a default Phoenix app, this is not a problem because of this line in test_helper.exs:

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

However, I removed that line from my project because in manual mode tests break when using worker processes in application.ex that access the database.

This means that data available to the 1st mount is different to the data available in the 2nd mount.

I humbly present one “hacky” way of making LiveView tests async.

We want to “allow” the 2nd mount process to use the 1st mount processes DB connection, so:

Make an on_mount module

defmodule MyAppWeb.OnMounts.AllowSandboxForLiveView do
  @moduledoc false

  alias Ecto.Adapters.SQL.Sandbox
  alias MyApp.Repo
  alias Phoenix.LiveView

  def on_mount(:default, _params, session, socket) do
    socket
    |> tap(&allow_sandbox(&1, session))
    |> then(&{:cont, &1})
  end

  defp allow_sandbox(socket, session) do
    with true <- LiveView.connected?(socket),
         unconnected_pid when is_pid(unconnected_pid) <- Map.get(session, "unconnected_pid"),
         true <- self() != unconnected_pid do
      Sandbox.allow(Repo, unconnected_pid, self())
    end
  end
end

Make a plug to get the PID in the session

defmodule MyAppWeb.Plugs.PutUnconnectedPidInSession do
  @moduledoc """
  Put the PID in the session, for testing purposes ONLY.
  """

  import Plug.Conn

  def init(_opts), do: nil

  if Application.compile_env(:my_app, :env) == :test do
    def call(conn, _opts), do: put_session(conn, :unconnected_pid, self())
  else
    def call(conn, _opts), do: conn
  end
end

Use the plug in router.ex

  pipeline :browser do
    # ...
    if Application.compile_env(:my_app, :env) == :test do
      plug MyAppWeb.Plugs.PutUnconnectedPidInSession
    end
   #...

Use the on_mount in all LiveViews. Go to my_app_web.ex

  def live_view do
    #...
      if Application.compile_env(:my_app, :env) == :test do
        on_mount MyAppWeb.OnMounts.AllowSandboxForLiveView
      end
    #...

And, finally, use the on_mount in all live_session in the router.ex.

if Application.compile_env(:my_app, :env) == :test do
  @preliminary_on_mounts [MyAppWeb.OnMounts.AllowSandboxForLiveView]
else
  @preliminary_on_mounts []
end

live_session,
#...
on_mount: @preliminary_on_mounts ++ [
  AssignUser,
  RequireUser,
#...etc

This is hacky and a bit ugly in the live_session bit but it makes LiveView work with

use MyApp.ConnCase, async: true

What is happening here:

As far as I know, the only way to share data between disconnected and connected mount is to use the session. In test environment only, put the PID in the session. Then during the connected mount use that PID to allow access to the Sandbox connection.

Note: if you want the Application.compile_env(:my_app, :env) to work then just add this line to config/test.exs:

config :my_app, :env, :test

And similar for dev/prod.

I eagerly welcome any and all feedback.

This looks a lot like Phoenix.Ecto.SQL.Sandbox — Phoenix/Ecto v4.6.3

3 Likes

I have never seen this. Thanks!

1 Like

Does this allow LiveView (and other ConnCase) tests to run async?

Normal ConnTests and LiveViewTest should work without messing with the sandbox. Dealing with the sandbox manually is only needed if you want to test only via http/websockets for integration tests through browsers.

2 Likes

which I suspect is what @slouchpie wants to do. Wallaby/browser tests?

I do not want to do acceptance tests. I just wanted to run my LiveView tests async.

@LostKobrakai does the approach in the link you posted above allow ConnCase tests to run async?

I do not think this should be required to do any custom plumbing in this set up.

Are you following this example? Phoenix.LiveViewTest — Phoenix LiveView v1.0.2

The “normal” setup as generated by mix phx.new has the sandbox running in manual mode, which is why the conn case tests can run async. However, this causes more complicated applications to crash in test, since they usually have worker processes that interact with the database.

The custom plumbing was already done :slight_smile:
I removed this line from test_helper.exs

Ecto.Adapters.SQL.Sandbox.mode(MyApp.Repo, :manual)
1 Like

I see. So you want to keep these background workers running in test and then also run the liveview tests in async mode?

Exactly that, yes. It has been a long road to get to this point TBH. I had problems with test data (I tried ex_machina, and then factori, before finding seed_factory). Then I had problems with various caching stuff. My final problem was the issue of “connected mount”. I just wanted to share my (hacky) solution to how I solved it.

If I get time, I will try the link provided above by @LostKobrakai , although it makes no mention of affording async tests.

1 Like

I’d just not start those workers (globally). They’re not really useful anyways because the test db is never meant to hold data anyways. For testing the workers you can start them within the tests as described here: Ecto.Adapters.SQL.Sandbox — Ecto SQL v3.12.1

1 Like

Thanks for the information. I would rather test this app as it is meant to run in reality. Every app is different and there is no perfect approach for every app.

Please let me know if the link you posted several comments ago affords running ConnCase tests with async: true. Thanks!

I’d say generally no. No solution will be able to do that. A worker started by the application acts on its own unrelated to an individual test, so which tests sandbox does it use? If you find some answer to that: What if that worker stores some internal state based on what it found in the db, now a test starts interacting with it. There’s a non-zero chance the internal state of the worker doesn’t match the state it’s supposed to hold base on the sandbox of the test. If it magically matches, what if multiple concurrent tests interact with the worker expecting it to hold distinct internal state related to each their active sandboxes.

In short: You cannot make a singleton – your one instance of globally started application with all its processes in the supervision tree – support arbitrary concurrent independent state handling.

The optimal state would be having a version of your application started per test. Given that’s often a lot of work and hard to coordinate the answer to that problem is commonly to test parts of that system in an concurrent fashion, while leaving testing of the assembled whole to integration/acceptance tests, which are fewer in number and do not run concurrently.

Even the link I posted above works only as far as scoping a single “user” on http requests/channels/LV to a sandbox, which may extend to further spawned processes, but once you get to globally spawned processes you again run into the problems I wrote in this post.

Thanks for all the good information. :+1: