Elixir hands you a working test suite the moment you run mix phx.new, but “it runs” is a low bar. Treating tests as part of the architecture—capturing how data flows, where collaborators sync up, and how failures get contained—is what separates a confident Phoenix team from one that tiptoes around regressions. This seven-part series is the playbook for building that confidence.
How the Series Works
Every article mirrors a pull request in rakshans1/ex-test, a Phoenix 1.8 Todo API that evolves with each pattern. You can open the linked PR, skim the diff, and run mix test to see the exact code behind every explanation—no toy snippets.
What We’ll Cover
- Part 1 – ExUnit Fundamentals (this post): Test organization,
describe/setup, pattern-matched assertions, and module attributes. - Part 2 – Database Isolation: Async vs sync strategies with
Ecto.Adapters.SQL.Sandboxand the DataCase blueprint. - Part 3 – Factory Patterns: ExMachina + Faker for expressive, composable test data.
- Part 4 – Behavior-Driven Mocking: Contracts via behaviours and Mox-powered expectations/stubs.
- Part 5 – Adapter & Stub Patterns: Configurable seams, deterministic clocks, and purpose-built doubles.
- Part 6 – Centralized Test Helpers: Reusable setup pipelines, stub helpers, and custom assertions.
- Part 7 – Phoenix Controller Coverage: ConnCase workflows, authentication simulators, and JSON API tests end to end.
By the end, you’ll have a production-ready testing toolkit: a mental model for structuring suites, a repo full of reference implementations, and a set of patterns you can apply the next time your team asks “did we test it?“.
Getting Started with ExUnit
ExUnit is Elixir’s built-in testing framework. It’s fast, concurrent by default, and designed for functional programming.
Project Structure
A typical Phoenix project’s test directory looks like this:
test/
├── support/
│ └── data_case.ex # Test helpers for database tests
├── ex_test/
│ ├── todos/
│ │ └── todo_test.exs # Schema/changeset tests
│ └── todos_test.exs # Context function tests
└── test_helper.exs # Test configuration
Your First Test Case
Let’s examine a simple test from the repository. Open test/ex_test/todos/todo_test.exs:
defmodule ExTest.Todos.TodoTest do
use ExTest.DataCase, async: true
alias ExTest.Todos.Todo
@valid_attrs %{
title: "Test Todo",
description: "Test description",
completed: false,
due_date: ~D[2025-12-31]
}
@invalid_attrs %{title: nil}
describe "changeset/2" do
test "valid changeset with all fields" do
changeset = Todo.changeset(%Todo{}, @valid_attrs)
assert changeset.valid?
end
test "invalid changeset without title" do
changeset = Todo.changeset(%Todo{}, @invalid_attrs)
refute changeset.valid?
assert %{title: ["can't be blank"]} = errors_on(changeset)
end
end
endLet’s break down what’s happening:
use ExTest.DataCase, async: true- Imports test helpers and enables parallel executionalias ExTest.Todos.Todo- Shortcut for the schema module@valid_attrs- Module attribute holding test data (we’ll cover this pattern in detail)describe "changeset/2"- Groups related tests logicallytest "description"- Individual test caseassertandrefute- Basic assertions
Run just this test file:
mix test test/ex_test/todos/todo_test.exsExUnit Core Concepts
Test Modules and Cases
Every test file defines a test module and uses either ExUnit.Case or a custom case module:
defmodule MyApp.SomeTest do
use ExUnit.Case, async: true
test "basic assertion" do
assert 1 + 1 == 2
end
endThe async: true option tells ExUnit this test can run in parallel with other async tests. This dramatically speeds up your test suite. You’ll learn when to use async: true vs async: false in Part 2 when we cover database testing with Ecto.Sandbox.
The describe Block
The describe block groups related tests. It makes test output more readable and lets you apply setup logic to specific groups:
describe "create_todo/1" do
test "creates todo with valid attributes using pattern matching" do
assert {:ok, %Todo{} = todo} = Todos.create_todo(@valid_attrs)
assert todo.title == "Learn Elixir"
assert todo.description == "Study ExUnit testing patterns"
assert todo.completed == false
assert todo.due_date == ~D[2025-12-31]
end
test "returns error changeset with invalid attributes" do
assert {:error, %Ecto.Changeset{} = changeset} =
Todos.create_todo(@invalid_attrs)
refute changeset.valid?
end
endWhen you run the tests, output shows the describe block context:
ExTest.TodosTest
create_todo/1
✓ creates todo with valid attributes using pattern matching
✓ returns error changeset with invalid attributes
Notice the pattern: we name the describe block after the function we’re testing (create_todo/1), making it crystal clear what each test covers.
Setup Callbacks
The setup callback runs before each test in a describe block (or before all tests in the module if outside a describe). It’s perfect for creating test data:
describe "get_todo!/1" do
setup do
{:ok, todo: insert(:todo)}
end
test "returns the todo with given id", %{todo: todo} do
assert Todos.get_todo!(todo.id) == todo
end
test "raises Ecto.NoResultsError when todo does not exist" do
assert_raise Ecto.NoResultsError, fn ->
Todos.get_todo!(0)
end
end
endThe setup block creates a todo and returns it in the context map. Tests receive this data via pattern matching in the test function signature: %{todo: todo}.
You can also use named setup functions:
describe "update_todo/2" do
setup :create_todo
test "updates todo with valid attributes", %{todo: todo} do
assert {:ok, %Todo{} = updated} = Todos.update_todo(todo, @update_attrs)
assert updated.title == "Updated Title"
end
end
defp create_todo(_context) do
{:ok, todo: insert(:todo)}
endThis pattern keeps tests DRY (Don’t Repeat Yourself) without hiding essential context.
Writing Effective Assertions
ExUnit provides simple, powerful assertions that leverage Elixir’s pattern matching.
assert and refute
The two fundamental assertions are assert (expects truthy value) and refute (expects falsy value):
test "valid changeset with all fields" do
changeset = Todo.changeset(%Todo{}, @valid_attrs)
assert changeset.valid? # Expects true
end
test "invalid changeset without title" do
changeset = Todo.changeset(%Todo{}, @invalid_attrs)
refute changeset.valid? # Expects false
endWhen an assertion fails, ExUnit shows you exactly what went wrong:
1) test create_todo/1 creates todo with valid attributes (ExTest.TodosTest)
test/ex_test/todos_test.exs:61
Expected truthy, got false
code: assert todo.title == "Wrong Title"
left: "Learn Elixir"
right: "Wrong Title"
Pattern Matching in Assertions
Here’s where Elixir really shines. Instead of separate assertions for type checks and value checks, use pattern matching:
test "creates todo with valid attributes using pattern matching" do
# Pattern match on the tuple AND extract the todo
assert {:ok, %Todo{} = todo} = Todos.create_todo(@valid_attrs)
# Now verify specific fields
assert todo.title == "Learn Elixir"
assert todo.description == "Study ExUnit testing patterns"
assert todo.completed == false
assert todo.due_date == ~D[2025-12-31]
endThis single line does three things:
- Asserts the function returns an
{:ok, _}tuple (not{:error, _}) - Asserts the second element is a
%Todo{}struct - Extracts the todo for further assertions
Compare this to testing frameworks in other languages where you’d need separate assertions for each check. Pattern matching makes Elixir tests concise and expressive.
Another powerful example from the repository:
test "returns error changeset with invalid attributes" do
assert {:error, %Ecto.Changeset{} = changeset} =
Todos.create_todo(@invalid_attrs)
refute changeset.valid?
endThe pattern match asserts we got an error tuple containing an Ecto.Changeset, then we can inspect the changeset’s validity.
Testing Error Conditions with assert_raise
Use assert_raise to verify a function raises a specific exception:
test "raises Ecto.NoResultsError when todo does not exist" do
assert_raise Ecto.NoResultsError, fn ->
Todos.get_todo!(0)
end
endYou can also match on the exception message:
test "raises with specific message" do
assert_raise Ecto.NoResultsError,
~r/expected at least one result/,
fn ->
Todos.get_todo!(0)
end
endThe function must be wrapped in fn -> ... end because assert_raise needs to catch the exception. If you pass the function call directly, it executes before assert_raise can catch it.
Organizing Test Data
Module Attributes for Test Data
Module attributes (variables prefixed with @) are perfect for test data that multiple tests need:
defmodule ExTest.TodosTest do
use ExTest.DataCase, async: true
@valid_attrs %{
title: "Learn Elixir",
description: "Study ExUnit testing patterns",
completed: false,
due_date: ~D[2025-12-31]
}
@update_attrs %{
title: "Updated Title",
description: "Updated description",
completed: true,
due_date: ~D[2026-01-15]
}
@invalid_attrs %{title: nil}
# Tests use these attributes...
endThis pattern has several advantages:
- Visibility: Test data is at the top of the file, easy to find and modify
- DRY: Define once, use in many tests
- Compile-time: Module attributes are evaluated at compile time
- Named semantics:
@valid_attrs,@invalid_attrs,@update_attrsclearly communicate intent
Why This Pattern Works
When you have multiple tests checking different aspects of the same operation, module attributes keep them consistent:
describe "create_todo/1" do
test "creates todo with valid attributes using pattern matching" do
assert {:ok, %Todo{} = todo} = Todos.create_todo(@valid_attrs)
assert todo.title == "Learn Elixir"
end
test "broadcasts event on successful creation" do
# Uses the same @valid_attrs
assert {:ok, todo} = Todos.create_todo(@valid_attrs)
assert_received {:todo_created, ^todo}
end
endIf you need to change the test data (maybe you add a new required field), you update it in one place.
However, be cautious with shared state. In Part 3 of this series, we’ll cover factories with ExMachina, which provide even more flexibility for test data generation.
Test Tags and Filtering
Tags let you categorize and selectively run tests. This is invaluable in large test suites where you might want to run only integration tests, or skip slow tests during development.
Module Tags vs Test Tags
Apply a tag to all tests in a module with @moduletag:
defmodule ExTest.TodosTest do
use ExTest.DataCase, async: true
@moduletag :todos
# All tests in this module are tagged :todos
endTag individual tests with @tag:
describe "update_todo/2" do
setup do
{:ok, todo: insert(:todo)}
end
@tag :edge_case
test "partial update only changes specified fields", %{todo: todo} do
assert {:ok, %Todo{} = updated} = Todos.update_todo(todo, %{completed: true})
assert updated.completed == true
assert updated.title == todo.title
end
endRunning Filtered Tests
Run only tests with a specific tag:
mix test --only todosExclude tests with a tag:
mix test --exclude edge_caseCombine multiple filters:
mix test --only integration --exclude slowYou can also configure default exclusions in test/test_helper.exs:
ExUnit.configure(exclude: [:skip, :pending])
ExUnit.start()Now tests tagged @tag :skip won’t run unless you explicitly include them:
mix test --include skipCommon tagging patterns:
:integration- Tests hitting external services:slow- Long-running tests:wip- Work in progress, not ready for CI:external- Requires network/API access
Running Your Tests
Basic Commands
Run all tests:
mix testRun a specific test file:
mix test test/ex_test/todos_test.exsRun a specific test by line number:
mix test test/ex_test/todos_test.exs:61This runs the test containing line 61 (you don’t need the exact line, just any line inside the test).
Useful Options
Watch mode (requires mix_test_watch dependency):
mix test.watchRun tests with coverage:
mix test --coverTrace test execution (shows test names as they run):
mix test --traceRun tests with detailed failure information:
mix test --failedThe --failed flag only runs tests that failed in the previous run. This is incredibly useful when fixing failing tests—you don’t have to wait for the entire suite.
Seed the random number generator for reproducible test order:
mix test --seed 42By default, ExUnit randomizes test order to catch tests that depend on each other. If you find a failure that only happens with a specific random seed, you can reproduce it exactly.
Summary
You’ve learned the fundamentals of testing in Elixir with ExUnit:
- Test structure: Modules, describe blocks, and individual test cases
- Setup callbacks: Sharing test data with
setupand named setup functions - Assertions:
assert,refute,assert_raise, and pattern matching in assertions - Test data: Module attributes for shared test data
- Tags: Categorizing and filtering tests with
@moduletagand@tag - Running tests: Commands and options for effective test execution
The real power of ExUnit emerges when combined with other tools in the Elixir testing ecosystem. In Part 2, we’ll explore Ecto.Sandbox for database test isolation—learn how to run hundreds of tests in parallel without them interfering with each other.
All code examples are available in the ex-test.
Series Navigation
Next: Part 2 - Database Isolation with Ecto.Sandbox
All Parts
- ExUnit Fundamentals (You are here)
- Database Isolation with Ecto.Sandbox
- Test Data with ExMachina
- Mocking with Mox
- Adapter Pattern and Stubs
- Centralized Test Helpers
- Phoenix Controller Testing
