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
endThe 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}
endon_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
endRun 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:trueIn 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:
- Never share global mutable state. If two tests write to the same ETS table or GenServer, they will race.
- Use
Ecto.Adapters.SQL.Sandboxfor database tests — it wraps each test in a transaction that rolls back automatically. - 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
endThe 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}
endstart_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
endUse 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
endThe 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
endActivate doctests in your test file:
defmodule MyApp.MathTest do
use ExUnit.Case, async: true
doctest MyApp.Math
endEach 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
endFor 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
endExUnit 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/1for per-test state andsetup_all/1for expensive shared setup; useon_exit/1for cleanup. - Tag tests with
@tagand@moduletagto enable selective CI execution without modifying code. - Enable
async: trueon 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; useEcto.Adapters.SQL.Sandboxfor database isolation.