ExUnit Fundamentals: The Complete Guide to Testing in Elixir
ExUnit ships with Elixir. No install, no configuration file to bootstrap, no framework ceremony. You write a module, use ExUnit.Case, and run mix test. That simplicity is intentional — the Elixir team treats testing as a first-class citizen, not an afterthought.
This guide covers everything you need to write effective tests with ExUnit: the module structure, callback lifecycle, assertion macros, doctests, tagging, CLI options, and coverage tooling.
The Basic Structure
Every test file follows the same shape:
defmodule MyApp.CalculatorTest do
use ExUnit.Case
test "adds two numbers" do
assert MyApp.Calculator.add(1, 2) == 3
end
test "raises on non-numeric input" do
assert_raise ArgumentError, fn ->
MyApp.Calculator.add("a", 1)
end
end
enduse ExUnit.Case injects the testing macros (test, assert, refute, etc.) into the module. The test runner discovers files matching *_test.exs under test/ by default. The .exs extension matters — these are script files that don't get compiled into your production build.
Async Tests
The most impactful option in ExUnit is async: true:
defmodule MyApp.PureLogicTest do
use ExUnit.Case, async: true
test "pure computation runs concurrently" do
result = MyApp.PureLogic.transform([1, 2, 3])
assert result == [2, 4, 6]
end
endWith async: true, test cases within this module run concurrently with other async modules. Tests within a single module still run sequentially. Enable it for any module that doesn't touch shared state — pure functions, stateless GenServers with their own isolated processes.
Never use async: true when:
- Tests touch a database without sandbox isolation
- Tests interact with ETS tables shared across processes
- Tests modify application environment with
Application.put_env/3 - Tests use global mocks that replace named processes
Mixing async and sync modules is fine. ExUnit runs all sync modules first (or last — it's scheduler-dependent), and async modules run concurrently among themselves.
Setup and Setup All Callbacks
setup/1 runs before each individual test. setup_all/1 runs once before the entire test module. Both return {:ok, context} or just a keyword list that gets merged into the test context.
defmodule MyApp.UserTest do
use ExUnit.Case, async: true
setup do
user = %MyApp.User{name: "Alice", email: "alice@example.com"}
{:ok, user: user}
end
test "formats display name", %{user: user} do
assert MyApp.User.display_name(user) == "Alice"
end
test "validates email format", %{user: user} do
assert MyApp.User.valid_email?(user.email)
end
endThe context map (%{user: user}) is pattern-matched in each test's second argument. This avoids repeating setup logic across tests.
setup_all is for expensive setup that doesn't change between tests — parsing a large fixture file, starting a supervised process, connecting to an external service in integration tests:
defmodule MyApp.IntegrationTest do
use ExUnit.Case
setup_all do
{:ok, pid} = MyApp.ExternalClient.start_link()
{:ok, client: pid}
end
setup %{client: client} do
# Per-test setup using the shared client
MyApp.ExternalClient.reset(client)
:ok
end
test "fetches data", %{client: client} do
assert {:ok, _data} = MyApp.ExternalClient.fetch(client, "resource-1")
end
endYou can chain multiple setup blocks — they run in order, and each receives the accumulated context.
Assertions
ExUnit's assertion macros give you clear failure messages without custom matchers.
assert and refute
assert value == expected
refute value == unexpected
# assert with a message
assert length(list) > 0, "expected non-empty list, got: #{inspect(list)}"When an assert fails, ExUnit diffs the left and right sides. For complex data structures, this diff is readable and precise.
assert_raise
assert_raise ArgumentError, "must be positive", fn ->
MyApp.Domain.create_order(quantity: -1)
end
# Without checking the message
assert_raise RuntimeError, fn ->
MyApp.Domain.explode()
endThe second argument (the message string) is optional. When provided, ExUnit checks that the exception message matches exactly.
assert_receive and assert_received
For message-passing tests:
test "sends notification on completion" do
MyApp.Worker.run(notify: self())
assert_receive {:done, result}, 1000
assert result.status == :ok
end
test "already in mailbox" do
send(self(), :ping)
assert_received :ping
endassert_receive waits up to the timeout (default 100ms) for the message. assert_received checks the current mailbox without waiting — useful when you know the message is already there.
Pattern Matching in Assertions
This is where Elixir's pattern matching shines in tests:
test "returns tagged tuple with user data" do
assert {:ok, %{name: "Alice", role: :admin}} = MyApp.Auth.authenticate("alice", "password")
end
test "error includes reason" do
assert {:error, %{code: :not_found}} = MyApp.Repo.fetch(User, 999)
endThe = operator here is a match, not an equality check. The right side can contain variables that get bound, and structural parts you don't care about can be ignored with _. This is more expressive than equality assertions for complex return types.
catch_error and catch_throw
test "throws on invalid state" do
error = catch_error(MyApp.StateMachine.transition(:invalid))
assert error.message =~ "invalid transition"
endDoctests
Doctests extract iex> examples from module documentation and run them as tests. They're not a replacement for unit tests — they're documentation that happens to be verified.
defmodule MyApp.StringUtils do
@moduledoc """
Utilities for string manipulation.
"""
@doc """
Capitalizes the first letter of each word.
## Examples
iex> MyApp.StringUtils.titlecase("hello world")
"Hello World"
iex> MyApp.StringUtils.titlecase("")
""
"""
def titlecase(""), do: ""
def titlecase(str) do
str
|> String.split(" ")
|> Enum.map(&String.capitalize/1)
|> Enum.join(" ")
end
endTo run doctests from a test file:
defmodule MyApp.StringUtilsTest do
use ExUnit.Case, async: true
doctest MyApp.StringUtils
endExUnit parses the iex> prompts and evaluates them. If the output doesn't match, the test fails with the location of the docstring.
Write doctests for public API surface — functions that other modules or users call. Don't write doctests for private functions or complex multi-step scenarios; those belong in regular tests.
Tagging Tests
Tags let you categorize tests and filter them at run time.
defmodule MyApp.SlowTest do
use ExUnit.Case
@tag :slow
test "processes 10k records" do
# takes 5 seconds
end
@tag :integration
@tag timeout: 30_000
test "calls external API" do
# integration test with custom timeout
end
@tag :wip
test "feature in progress" do
# not ready yet
end
endRun or exclude by tag:
# Only run slow tests
mix test --only slow
# Skip integration tests
mix test --exclude integration
# Run everything except wip
mix test --exclude wipThe timeout tag is built-in — it sets the per-test timeout in milliseconds (default: 60,000ms).
You can also configure excluded tags globally in test/test_helper.exs:
ExUnit.start(exclude: [:integration, :slow])This means mix test skips those tags by default, but mix test --include integration re-enables them for CI runs where you want the full suite.
Mix Test Options
Filtering by file and line
# Run a single file
mix test test/my_app/calculator_test.exs
# Run a specific test by line number
mix test test/my_app/calculator_test.exs:42
# Run multiple files
mix test test/my_app/calculator_test.exs test/my_app/user_test.exsSeed
ExUnit randomizes test order by default. The seed is printed at the end of each run:
Randomized with seed 123456Reproduce a specific run order:
mix test --seed 123456This is invaluable for debugging order-dependent test failures. When a test only fails in certain orders, fixing it by running --seed 0 (no randomization) is hiding the bug, not fixing it.
Stale and failed
# Only run tests in files changed since last run
mix test --stale
# Only run tests that failed in the previous run
mix test --failed--failed is your best friend during a debugging session. Fix the failure, run mix test --failed to verify, then run the full suite.
Verbosity and formatting
# Show each test name as it runs
mix test --trace
# Stop after first failure
mix test --max-failures 1--trace implies async: false — all tests run sequentially so output isn't interleaved. Use it when you need to read the full test sequence.
Test Coverage with ExCoveralls
ExUnit doesn't ship with a coverage reporter, but excoveralls integrates tightly with mix test.
Add to mix.exs:
def project do
[
# ...
test_coverage: [tool: ExCoveralls],
preferred_cli_env: [
coveralls: :test,
"coveralls.detail": :test,
"coveralls.post": :test,
"coveralls.html": :test
]
]
end
defp deps do
[
{:excoveralls, "~> 0.18", only: :test}
]
endRun coverage:
# Summary in terminal
mix coveralls
# Detailed per-line output
mix coveralls.detail
# HTML report in cover/excoveralls.html
mix coveralls.htmlConfigure coverage thresholds and exclusions in coveralls.json:
{
"coverage_options": {
"minimum_coverage": 80,
"treat_no_relevant_lines_as_covered": true
},
"skip_files": [
"lib/my_app_web/telemetry.ex",
"lib/my_app/repo.ex",
"test/"
]
}The minimum_coverage option fails the build (exit code 1) when coverage drops below the threshold. Wire this into CI to prevent coverage regressions:
# GitHub Actions
- name: Run tests with coverage
run: mix coveralls.github
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}coveralls.github posts a coverage comment to the pull request.
Structuring Your Test Suite
A few conventions that scale well:
Mirror your lib structure. lib/my_app/accounts/user.ex → test/my_app/accounts/user_test.exs. Predictable, searchable.
One module per test file. Testing MyApp.Accounts.User in MyApp.Accounts.UserTest. Don't jam multiple unrelated modules into one test file.
Separate unit from integration. Tag integration tests with @tag :integration and exclude them from the default run. Run them in CI on a separate step with a real database.
Use describe blocks for grouping. ExUnit supports describe for logical grouping within a test module:
defmodule MyApp.OrderTest do
use ExUnit.Case, async: true
describe "create/1" do
test "succeeds with valid params" do ... end
test "fails when quantity is zero" do ... end
test "fails when product not found" do ... end
end
describe "cancel/1" do
test "marks order as cancelled" do ... end
test "raises when order already shipped" do ... end
end
enddescribe blocks scope setup callbacks — a setup inside a describe only runs for tests in that block.
What ExUnit Doesn't Cover
ExUnit handles unit and integration tests well. It doesn't handle:
- Browser-based E2E tests — for those, look at Wallaby or Hound
- Load testing — separate tooling (k6, Locust)
- Visual regression — needs screenshot comparison tools
- Cross-service flow testing — when your Elixir service is one piece of a larger system
For monitoring those E2E flows in production and catching regressions before users do, teams often pair ExUnit with a platform like HelpMeTest — plain-English test scenarios that run continuously against your live environment, no extra code required.
ExUnit's strength is speed and integration with the Elixir toolchain. Learn it well and you'll write tests that are fast to run, easy to read, and hard to ignore.