Split async and sync tests in one file

almirsarajcic

almirsarajcic

yesterday

0 comments

Marking a test module async: false serializes every test inside it. If only two tests genuinely need it — because they touch a globally-named Task.Supervisor or a singleton GenServer — the other thirty tests pay the serial cost for no reason.

Two defmodule blocks in one file fixes this. ExUnit discovers and runs them as independent modules: the async: true module runs in parallel with everything else; the async: false module runs serially on its own. Only the genuinely-blocking tests pay the cost.

defmodule MyAppWeb.WebhookControllerTest do
  use MyAppWeb.ConnCase, async: true

  describe "GET /webhook — verification" do
    test "returns challenge for valid request", %{conn: conn} do
      # Safe async: no task spawned, assertion is on HTTP response only
    end

    test "rejects missing token", %{conn: conn} do
      # Safe async: validation happens in the request process
    end
  end
end

defmodule MyAppWeb.WebhookControllerSyncTest do
  # async: false — POST handler spawns via MyApp.TaskSupervisor (globally named).
  # The task writes to DB; per-test Sandbox.allow races under concurrency.
  use MyAppWeb.ConnCase, async: false

  describe "POST /webhook — message handling" do
    test "persists incoming message", %{conn: conn} do
      post(conn, ~p"/webhook", valid_payload())
      # Wait for the spawned task to complete
      Process.sleep(100)
      assert Repo.aggregate(Message, :count) == 1
    end
  end
end

The blocker comment on *SyncTest is mandatory. Without it the next developer cleaning up test performance will flip it to async: true, the flake returns, and nobody knows why. The comment must name the specific global resource and explain the mechanism — not just say “async: false for safety”.

What forces a SyncTest split:

  • Task.Supervisor.start_child(GloballyNamedSup, fn -> Repo.query... end) — the supervisor is shared, so Sandbox.allow for test A races with test B’s concurrent allow on the same supervisor process
  • Ecto.Adapters.SQL.Sandbox.mode(Repo, :shared) in setup — shared mode disables per-test isolation
  • Singleton GenServer state (MyApp.Cache, MyApp.RateLimiter) that tests mutate without a reset hook

What does NOT need a split:

  • Oban.Testing with testing: :inline — jobs run in the calling process, inherit sandbox ownership, safe for async
  • Task.async/1 — inherits $callers, sandbox ownership propagates automatically
  • Req.Test.stub — per-process by default, async-safe
  • Process.put config overrides — discarded per test automatically

Name the sync module <ModuleName>SyncTest and keep it in the same file as <ModuleName>Test. A separate _sync_test.exs file breaks the connection between the two and makes the blocker comment hard to find.

ExUnit.Case async option docs

Comments (0)

Sign in with GitHub to join the discussion