ExUnit Advanced Patterns: Setup, Tags, Async Tests, and Custom Assertions

ExUnit Advanced Patterns: Setup, Tags, Async Tests, and Custom Assertions

ExUnit ships with everything you need for production-grade Elixir testing, but most developers only scratch the surface. This post covers setup/teardown, tags, async safety, custom assertions, doctests, and isolation patterns that keep large test suites fast and reliable.

ExUnit is Elixir's built-in test framework, and it punches well above its weight. Unlike frameworks bolted on after the fact, ExUnit was designed alongside OTP — which means it understands processes, supervision trees, and concurrency from the ground up. Once you move past assert and refute, you unlock a suite of tools that make large codebases a pleasure to test.

Setup and Teardown with setup and setup_all

ExUnit provides two lifecycle callbacks: setup/1 runs before each test, and setup_all/1 runs once before all tests in the module. Both receive a context map and can merge additional keys into it.

defmodule MyApp.AccountTest do
  use ExUnit.Case, async: true

  setup_all do
    # Expensive setup done once per module
    {:ok, pid} = MyApp.Cache.start_link([])
    %{cache: pid}
  end

  setup %{cache: cache} do
    # Per-test setup receives the context from setup_all
    MyApp.Cache.flush(cache)
    user = %MyApp.User{id: 1, email: "test@example.com", balance: 100}
    {:ok, user: user}
  end

  test "debit reduces balance", %{user: user, cache: cache} do
    {:ok, updated} = MyApp.Account.debit(user, 30, cache)
    assert updated.balance == 70
  end

  test "debit rejects overdraft", %{user: user, cache: cache} do
    assert {:error, :insufficient_funds} = MyApp.Account.debit(user, 200, cache)
  end
end

The key insight: returning {:ok, keyword_or_map} from setup merges keys into the test context. Returning :ok leaves the context unchanged. If setup fails, ExUnit marks the test as failed and skips it — preventing misleading failures downstream.

For teardown, use on_exit/1 inside setup:

setup do
  {:ok, server} = MyApp.Server.start_link([])

  on_exit(fn ->
    # Runs after each test, even if the test fails
    MyApp.Server.stop(server)
  end)

  {:ok, server: server}
end

on_exit callbacks run in a separate process after the test completes, which is important: the test process may already be dead when cleanup runs. Keep callbacks free of assertions — failures there are harder to diagnose.

Tags for Selective Execution

Tags let you mark tests and filter them at run time with --only, --exclude, or --include.

defmodule MyApp.SlowTest do
  use ExUnit.Case

  @moduletag :slow

  @tag :wip
  test "experimental feature under development" do
    # ...
  end

  @tag external: true, timeout: 10_000
  test "calls third-party payment API" do
    # ...
  end
end

Run only fast tests in CI, reserve slow ones for nightly:

# Skip anything tagged :slow
mix test --exclude slow

# Run only tests tagged :wip
mix test --only wip

# Run tests tagged external with a specific value
mix test --only external:true

In test/test_helper.exs, you can configure exclusions globally:

ExUnit.start(exclude: [:slow, :external])

This is particularly useful when you have a @tag :integration group that requires a live database or network access — developers can skip them locally and let CI handle the full suite.

Async Tests

use ExUnit.Case, async: true tells ExUnit it can run this module concurrently with other async modules. This can cut test suite time dramatically on multi-core machines, but it requires discipline.

Rules for safe async tests:

  1. Never share global mutable state. If two tests write to the same ETS table or GenServer, they will race.
  2. Use Ecto.Adapters.SQL.Sandbox for database tests — it wraps each test in a transaction that rolls back automatically.
  3. Start processes under the test supervisor, not globally.
defmodule MyApp.RepoTest do
  use MyApp.DataCase, async: true  # DataCase sets up sandbox

  test "creates a user" do
    assert {:ok, user} = MyApp.Accounts.create_user(%{email: "a@b.com"})
    assert user.id != nil
  end
end

The DataCase template (generated by mix phx.new) calls Ecto.Adapters.SQL.Sandbox.checkout/1 in setup and Sandbox.mode/2 to allow other processes to share the same connection when needed.

For GenServer isolation, start servers under start_supervised!/1:

setup do
  server = start_supervised!({MyApp.Worker, name: nil})
  {:ok, server: server}
end

start_supervised! links the process to the ExUnit test supervisor. When the test finishes, the process is terminated automatically — no cleanup needed, no state leaking between tests.

Custom Assertions

Standard assert is fine, but custom assertions produce far better failure messages and make tests read like documentation.

defmodule MyApp.Assertions do
  import ExUnit.Assertions

  def assert_email_sent(to: email) do
    assert_received {:email_sent, ^email},
      "Expected an email to #{email} but none was sent. " <>
      "Check that MyApp.Mailer.deliver/1 was called."
  end

  def assert_approx_equal(actual, expected, delta \\ 0.001) do
    diff = abs(actual - expected)
    assert diff <= delta,
      "Expected #{actual} to be within #{delta} of #{expected}, " <>
      "but difference was #{diff}"
  end

  def assert_changeset_error(changeset, field, message) do
    errors = Ecto.Changeset.traverse_errors(changeset, fn {msg, _opts} -> msg end)
    field_errors = Map.get(errors, field, [])
    assert message in field_errors,
      "Expected field #{inspect(field)} to have error #{inspect(message)}, " <>
      "got: #{inspect(field_errors)}"
  end
end

Use them in tests:

defmodule MyApp.RegistrationTest do
  use ExUnit.Case, async: true
  import MyApp.Assertions

  test "sends welcome email on registration" do
    MyApp.Accounts.register(%{email: "new@user.com", password: "secret123"})
    assert_email_sent(to: "new@user.com")
  end

  test "rejects duplicate email" do
    changeset = MyApp.Accounts.registration_changeset(%{email: "taken@user.com"})
    assert_changeset_error(changeset, :email, "has already been taken")
  end
end

The pattern assert_received checks the current process mailbox — useful when you send messages to self() from within the system under test to track side effects.

Doctests

Elixir's @doc attribute supports embedded examples that ExUnit can run as tests. This keeps documentation and behavior in sync automatically.

defmodule MyApp.Math do
  @doc """
  Calculates compound interest.

  ## Examples

      iex> MyApp.Math.compound_interest(1000, 0.05, 12)
      1795.8563260221573

      iex> MyApp.Math.compound_interest(0, 0.1, 5)
      0.0

      iex> MyApp.Math.compound_interest(500, 0.03, 1)
      515.0
  """
  def compound_interest(principal, rate, years) do
    principal * :math.pow(1 + rate, years)
  end
end

Activate doctests in your test file:

defmodule MyApp.MathTest do
  use ExUnit.Case, async: true
  doctest MyApp.Math
end

Each iex> block becomes a test case. If the function output ever diverges from the example, the doctest fails. For multiline results, use ...> continuation lines. For results that vary (like PIDs), use #PID<...> patterns or wrap in inspect/1.

One caveat: doctests run in the module scope, not in a setup context. They cannot access database sandboxes or process-local state. Keep them pure — doctests are for illustrating behavior, not for exhaustive coverage.

Test Isolation with Process Dictionary and Sandbox

Elixir's process dictionary (Process.put/2, Process.get/2) is per-process and inherently isolated — each test process gets a clean dictionary. This makes it a safe place to store per-test configuration:

defmodule MyApp.FeatureFlagTest do
  use ExUnit.Case, async: true

  setup do
    Process.put(:feature_flags, %{new_checkout: false})
    :ok
  end

  test "old checkout path is used by default" do
    refute MyApp.FeatureFlags.enabled?(:new_checkout)
  end

  test "new checkout path when flag enabled" do
    Process.put(:feature_flags, %{new_checkout: true})
    assert MyApp.FeatureFlags.enabled?(:new_checkout)
  end
end

For more structured test configuration, Application.put_env/3 is another option, but it is global state — unsafe in async tests. Prefer passing configuration explicitly via function arguments or using process-local state.

Putting It All Together

Here is a realistic module that combines all these patterns:

defmodule MyApp.OrderProcessorTest do
  use MyApp.DataCase, async: true
  import MyApp.Assertions

  @moduletag :orders

  setup_all do
    {:ok, catalog: MyApp.Factory.create_catalog()}
  end

  setup %{catalog: catalog} do
    user = start_supervised!({MyApp.SessionStore, user_id: 42})
    order = MyApp.Factory.build_order(catalog: catalog, user_id: 42)
    {:ok, order: order, session: user}
  end

  @tag :slow
  test "processes a valid order end to end", %{order: order} do
    assert {:ok, receipt} = MyApp.OrderProcessor.process(order)
    assert receipt.total > 0
    assert_email_sent(to: "customer@example.com")
  end

  test "rejects order with empty cart", %{order: order} do
    empty = %{order | items: []}
    assert {:error, :empty_cart} = MyApp.OrderProcessor.process(empty)
  end

  test "applies discount code", %{order: order} do
    discounted = MyApp.OrderProcessor.apply_code(order, "SAVE10")
    assert_approx_equal(discounted.total, order.total * 0.90)
  end
end

ExUnit rewards consistency. The more you lean on setup, tags, and custom assertions, the more your test suite becomes its own layer of documentation — failures point directly to what broke, and passing tests are a specification you can trust.

Key Takeaways

  • Use setup/1 for per-test state and setup_all/1 for expensive shared setup; use on_exit/1 for cleanup.
  • Tag tests with @tag and @moduletag to enable selective CI execution without modifying code.
  • Enable async: true on every module that doesn't touch global state — it's the single biggest speed lever.
  • Write custom assertion helpers that produce domain-specific failure messages.
  • Embed doctests in public API modules to keep docs and behavior in sync.
  • Use start_supervised! for process isolation; use Ecto.Adapters.SQL.Sandbox for database isolation.

Read more

Start now free