Advanced ExUnit: Tags, Doctests, Async Tests, and Custom Assertions
ExUnit is Elixir's built-in test framework. Most guides cover the basics — assert, refute, describe. But ExUnit has powerful features that separate good test suites from great ones: tags for selective execution, doctests that keep documentation accurate, async execution for faster runs, and custom assertions that make failures meaningful.
This guide covers these advanced features with practical patterns for real Elixir applications.
Test Tags
Tags let you categorize tests and run subsets of your suite — critical for large codebases and CI pipelines.
Defining Tags
defmodule MyApp.UserTest do
use ExUnit.Case
@tag :integration
test "connects to real database" do
# ...
end
@tag :slow
test "processes large batch" do
# ...
end
@tag integration: true, slow: true
test "full export pipeline" do
# ...
end
@tag :pending
test "feature not yet implemented" do
# This will still fail if it runs — :pending doesn't skip by default
end
endFiltering by Tag
# Run only integration tests
mix <span class="hljs-built_in">test --only integration
<span class="hljs-comment"># Exclude slow tests
mix <span class="hljs-built_in">test --exclude slow
<span class="hljs-comment"># Combine filters
mix <span class="hljs-built_in">test --only integration --exclude slow
<span class="hljs-comment"># Run tests with specific tag value
mix <span class="hljs-built_in">test --only integration:<span class="hljs-literal">trueConfiguring in test_helper.exs
# test/test_helper.exs
ExUnit.start()
# Skip integration tests by default (only run with --include integration)
ExUnit.configure(exclude: [:integration])
# Skip tests tagged :wip
ExUnit.configure(exclude: [:wip])With exclude: [:integration] in config, integration tests only run when explicitly included:
mix test --include integrationThis pattern keeps CI fast by default while allowing full runs when needed.
Module-Level Tags
Apply tags to an entire module with @moduletag:
defmodule MyApp.PaymentIntegrationTest do
use ExUnit.Case
@moduletag :integration
@moduletag timeout: 60_000 # override timeout for all tests
test "charges card successfully" do
# ...
end
test "handles declined card" do
# ...
end
endDoctests
Doctests extract iex> examples from @doc attributes and run them as tests. They keep documentation accurate — if the code changes and the docs don't, the test fails.
Writing Doctests
defmodule MyApp.StringUtils do
@doc """
Truncates a string to the given length, appending "..." if truncated.
## Examples
iex> MyApp.StringUtils.truncate("Hello, World!", 5)
"Hello..."
iex> MyApp.StringUtils.truncate("Hi", 10)
"Hi"
iex> MyApp.StringUtils.truncate("", 5)
""
"""
def truncate(string, max_length) when byte_size(string) <= max_length, do: string
def truncate(string, max_length) do
String.slice(string, 0, max_length) <> "..."
end
endEnabling Doctests
defmodule MyApp.StringUtilsTest do
use ExUnit.Case, async: true
doctest MyApp.StringUtils
enddoctest/1 generates one test per iex> example block. A "block" is a contiguous sequence of iex> and ...> lines that produces a single result.
Multi-Line Doctests
@doc """
Builds a user map from keyword options.
## Examples
iex> MyApp.UserBuilder.build(name: "Alice", role: :admin)
%{name: "Alice", role: :admin, active: true}
iex> opts = [name: "Bob", role: :member]
...> MyApp.UserBuilder.build(opts)
%{name: "Bob", role: :member, active: true}
"""Doctest Options
# Run only specific examples by line number
doctest MyApp.StringUtils, only: [truncate: 2]
# Exclude specific examples
doctest MyApp.StringUtils, except: [truncate: 2]
# Import module into doctest scope
doctest MyApp.StringUtils, import: trueTesting Exceptions in Doctests
@doc """
Parses an integer, raising on invalid input.
## Examples
iex> MyApp.Parser.parse_int!("42")
42
iex> MyApp.Parser.parse_int!("abc")
** (ArgumentError) invalid integer: "abc"
"""The ** (ExceptionModule) message format matches exception doctests.
Async Tests
By default, ExUnit runs test modules sequentially. Adding async: true runs modules concurrently — a significant speedup for large suites.
defmodule MyApp.UserTest do
use ExUnit.Case, async: true
test "normalizes email" do
assert User.normalize_email("ALICE@EXAMPLE.COM") == "alice@example.com"
end
endWhen to use async: true:
- Pure function tests with no shared state
- Tests that use
Ecto.Adapters.SQL.Sandbox(it handles concurrent DB access) - Tests using in-memory data structures
When NOT to use async: true:
- Tests that write to shared files or directories
- Tests that rely on global process state (
Application.put_env, ETS tables) - Tests using external services without isolation
Async with Ecto
Ecto's SQL sandbox allows concurrent database tests:
# test/test_helper.exs
Ecto.Adapters.SQL.Sandbox.mode(MyApp.Repo, :manual)
# test/support/data_case.ex
defmodule MyApp.DataCase do
use ExUnit.CaseTemplate
using do
quote do
alias MyApp.Repo
import Ecto
import Ecto.Changeset
import Ecto.Query
import MyApp.DataCase
end
end
setup tags do
pid = Ecto.Adapters.SQL.Sandbox.start_owner!(MyApp.Repo, shared: not tags[:async])
on_exit(fn -> Ecto.Adapters.SQL.Sandbox.stop_owner(pid) end)
:ok
end
endTests using MyApp.DataCase can safely use async: true — each test gets an isolated transaction that rolls back after the test.
defmodule MyApp.UserRepoTest do
use MyApp.DataCase, async: true # concurrent DB tests!
test "creates user" do
{:ok, user} = MyApp.Users.create_user(%{email: "alice@example.com"})
assert user.id
end
endCustom Assertions
ExUnit's assert with patterns is powerful, but custom assertion functions provide better failure messages for domain-specific checks.
Basic Custom Assertion
defmodule MyApp.Assertions do
import ExUnit.Assertions
def assert_valid_changeset(%Ecto.Changeset{} = changeset) do
assert changeset.valid?,
"Expected changeset to be valid, but got errors: #{inspect(changeset.errors)}"
end
def assert_invalid_changeset(%Ecto.Changeset{} = changeset, field) do
refute changeset.valid?,
"Expected changeset to be invalid on #{field}"
assert Keyword.has_key?(changeset.errors, field),
"Expected error on #{field}, got errors: #{inspect(changeset.errors)}"
end
def assert_email_sent(to: email) do
assert_received {:email, %{to: ^email}},
"Expected email sent to #{email}, but none was received"
end
endUsing Custom Assertions
defmodule MyApp.UserTest do
use ExUnit.Case
import MyApp.Assertions
test "validates email format" do
changeset = User.changeset(%User{}, %{email: "not-an-email"})
assert_invalid_changeset(changeset, :email)
end
test "accepts valid email" do
changeset = User.changeset(%User{}, %{email: "alice@example.com", name: "Alice"})
assert_valid_changeset(changeset)
end
endPattern-Based Custom Assertions
ExUnit's assert_receive with pattern matching is already powerful:
test "publishes event on order completion" do
:ok = OrderService.complete_order(order_id: 42)
assert_receive {:event, :order_completed, %{order_id: 42, total: _}}, 1000
end
test "sends correct notification type" do
NotificationService.notify_user(user_id: 1, type: :welcome)
assert_receive {:notification, user_id: 1, type: :welcome, channel: channel}
assert channel in [:email, :push]
endExUnit.Callbacks for Setup
defmodule MyApp.PaymentTest do
use ExUnit.Case
setup do
# Runs before each test
user = Factory.insert(:user)
{:ok, user: user}
end
setup_all do
# Runs once before all tests in this module
{:ok, config: Application.get_env(:my_app, :payment)}
end
test "charges card", %{user: user} do
assert {:ok, _} = PaymentService.charge(user, amount: 100)
end
endTest Callbacks and On Exit
setup do
original_env = Application.get_env(:my_app, :feature_flags)
on_exit(fn ->
Application.put_env(:my_app, :feature_flags, original_env)
end)
Application.put_env(:my_app, :feature_flags, %{new_feature: true})
:ok
endon_exit runs after each test, even on failure — perfect for cleanup.
ExUnit.CaseTemplate
Share setup across multiple test modules:
defmodule MyApp.ApiCase do
use ExUnit.CaseTemplate
using do
quote do
use MyApp.DataCase
import MyApp.ApiCase
import Plug.Conn
import Phoenix.ConnTest
@endpoint MyApp.Endpoint
end
end
setup %{conn: conn} do
{:ok, conn: put_req_header(conn, "accept", "application/json")}
end
def authenticate(conn, user) do
token = MyApp.Auth.generate_token(user)
put_req_header(conn, "authorization", "Bearer #{token}")
end
endUse it in controller tests:
defmodule MyAppWeb.UserControllerTest do
use MyApp.ApiCase
test "GET /users/:id", %{conn: conn} do
user = Factory.insert(:user)
authed_conn = authenticate(conn, user)
response = get(authed_conn, ~p"/api/users/#{user.id}")
assert response.status == 200
end
endSummary
Advanced ExUnit features scale with your application. Tags separate fast unit tests from slow integration tests and enable targeted CI pipelines. Doctests keep examples in sync with implementation. Async execution cuts wall-clock test time dramatically. Custom assertions provide domain-meaningful failure messages that reduce debugging time.
Master these patterns and your Elixir test suite becomes both faster to run and easier to maintain. Complement unit testing with HelpMeTest for continuous production monitoring — catching the failures that only appear under real traffic and real infrastructure.