Best way of doing an integration test that spans a Task.start?

I’m working on an integration test that hits a user registration path in Phoenix (using mix.gen.auth). It worked as below:

test "send registration token if email is not within system", %{conn: conn} do
  conn =
    post(conn, Routes.user_registration_path(conn, :create), %{
      "user" => %{"email" => "unknown@example.com"}
    })

  assert redirected_to(conn) == "/"
  assert get_flash(conn, :info) =~ "sign-in link"
  assert Repo.all(Accounts.UserToken) != []
end

Then I wrapped the part of the user registration responsible for sending email in a Task.start so as not to delay rendering of the page while an email is sent. This breaks the tests since generating a registration email takes longer than the tests to complete.

What approach would you take for an end-to-end test with an asynchronous component like this? I’m currently evaluating the following:

  • Making the test sleep (easy but can lead to non-deterministic failures)
  • Rewriting the Task.start with Task.async in the registration controller, adding a pid to all necessary function params and using assert_receive from the test. This pollutes the controller code and still won’t work end to end since the test couldn’t pass the pid to the post
  • Writing a utility function that passes a function to Task.start, unless the mix_env is test, in which case it just runs the function. This is simple, but seems to be a bad path, longer term.

What’s the better solution?

1 Like

Use oban and its existing test helpers to assert against and possibly execute async tasks.

3 Likes

You should be running your tasks under a supervisor, so another approach would be something like this:

defmodule MyApp.SomeSupervisor do
  def start_some_task(fun, args) do
    Task.Supervisor.start_child(__MODULE__, ...)
  end

  # For tests: find all children spawned by this supervisor and wait until they finish.
  def wait_for_completion() do
    pids = Task.Supervisor.children(__MODULE__)
    Enum.each(pids, &Process.monitor/1)
    wait_for_pids(pids)
  end

  defp wait_for_pids([]), do: nil

  defp wait_for_pids(pids) do
    receive do
      {:DOWN, _ref, :process, pid, _reason} -> wait_for_pids(List.delete(pids, pid))
    end
  end
end

In your test, call MyApp.SomeSupervisor.wait_for_completion(). It’s not perfect, since it might needlessly wait for tasks not related to your particular tests, but it my case worked good enough.

4 Likes

Which Elixir mailer library do you use?

1 Like

It’s not the same across every project

Interesting. Could you elaborate? I haven’t used Oban much and was under the impression it was mostly for working with job queue. Would you wrap each Task.start with some kind of call to Oban?

2 Likes