In Part 5, you mastered the adapter pattern and reusable stub implementations. You learned to connect mocks to stubs with stub_with/2, organize stub variants as nested modules, and test time-dependent logic deterministically. But as your test suite grows, you face a new challenge: test setup becomes repetitive and scattered across files. This is Part 6 of our 7-part series, where we centralize test utilities into reusable helper modules.
Every test needs infrastructure: mocking external services, creating test data, and making assertions. Without organization, each test file duplicates setup logic. You stub the email service in 15 different files. You write the same “assert todo is valid” logic repeatedly. Your tests become cluttered with boilerplate instead of focusing on business logic.
The solution: centralized helper modules. Group related test utilities into three categories: StubHelper for mock configuration, AssertHelper for domain-specific assertions, and SetupHelper for reusable data setup. Compose these helpers using ExUnit’s setup pattern. The result: tests that are concise, readable, and maintainable.
This post corresponds to PR #7: Centralized Test Helpers in ex-test. You’ll see exactly how to organize test helpers, compose setup functions, and build chainable assertions.
The Problem: Test Setup Repetition
Let’s start with what happens when test suites grow without helper organization.
Recognizing the Pattern
Consider a typical test file without helpers:
defmodule ExTest.TodosTest do
use ExTest.DataCase, async: true
import Mox
setup :verify_on_exit!
test "creates todo and sends notification" do
# Mock setup repeated in every test
stub_with(ExTest.EmailMock, ExTest.Stubs.EmailStub)
stub_with(ExTest.StorageMock, ExTest.Stubs.StorageStub)
stub_with(ExTest.ClockMock, ExTest.Stubs.ClockStub)
# Data setup repeated everywhere
todo = insert(:todo, title: "Test todo")
# Business logic being tested
result = Todos.create_and_notify(todo)
# Verbose assertions
assert result.id
assert result.title
assert result.inserted_at
end
test "completes todo and archives" do
# Same mock setup again
stub_with(ExTest.EmailMock, ExTest.Stubs.EmailStub)
stub_with(ExTest.StorageMock, ExTest.Stubs.StorageStub)
stub_with(ExTest.ClockMock, ExTest.Stubs.ClockStub)
# More data setup
todo = insert(:todo, completed: false)
result = Todos.complete_and_archive(todo)
# Same validation logic repeated
assert result.id
assert result.title
assert result.completed == true
end
endThis pattern repeats across your entire test suite. Each test file stubs the same services, creates similar test data, and validates the same invariants. The problems compound:
Duplication: The same three stub_with/2 calls appear in dozens of tests. Change how you want to stub email and you update 50 files.
Inconsistency: Some tests stub all services, some stub only what they need. Some create todos with factories, others inline. Tests become unpredictable.
Noise: Tests are 80% setup and 20% actual testing. Reading tests requires filtering through infrastructure to find the business logic.
Maintenance burden: Adding a new service means updating every test file. Refactoring assertions means finding every occurrence.
You’ve seen this before with stub implementations in Part 5. The same principle applies to all test utilities: centralize common patterns.
What Makes Good Test Helpers
Before building helpers, understand what makes them effective:
Composability: Helpers should work together. Combine stub configuration with data setup with assertions, all in the same test.
Context pattern: Helpers accept and return context maps. This enables ExUnit’s composable setup pattern with setup [:helper1, :helper2].
Discoverability: Organize helpers by category (stubs, setup, assertions). Clear names make them easy to find and understand.
Single responsibility: Each helper does one thing. Don’t mix stub configuration with data creation. Separation enables flexible composition.
Chainability: Assertions return their input for fluent APIs. Write todo |> assert_valid() |> assert_completed() instead of separate statements.
Documentation: Helper modules should explain usage patterns. Examples show how to compose helpers together.
Good helpers feel like language extensions. Tests read like specifications, not infrastructure setup.
StubHelper: Centralized Mock Configuration
Start with the most common repetition: mock configuration.
Basic Stub Helpers
Create a module that wraps stub_with/2 calls:
defmodule ExTest.StubHelper do
import Mox
def with_email_success(context \\ %{}) do
stub_with(ExTest.EmailMock, ExTest.Stubs.EmailStub)
context
end
def with_email_failure(context \\ %{}) do
stub_with(ExTest.EmailMock, ExTest.Stubs.EmailStub.Error)
context
end
def with_storage_success(context \\ %{}) do
stub_with(ExTest.StorageMock, ExTest.Stubs.StorageStub)
context
end
def with_fixed_clock(context \\ %{}) do
stub_with(ExTest.ClockMock, ExTest.Stubs.ClockStub)
context
end
endEach function does three things:
Accepts context: The parameter context \\ %{} makes the function work both standalone (with_email_success()) and with ExUnit’s setup pattern.
Configures mock: Calls stub_with/2 to connect the mock to a stub implementation. This is the same stub you built in Part 5.
Returns context: Passes the context through unchanged. This enables chaining and composition.
The naming convention is descriptive: with_ prefix indicates these are setup helpers. The name describes what behavior you’re enabling: email_success, storage_success.
Now tests can use helpers instead of direct stub_with/2 calls:
describe "email notifications" do
setup :with_email_success
test "sends reminder email" do
todo = insert(:todo)
assert :ok = Todos.send_reminder(todo)
end
test "sends completion email" do
todo = insert(:todo, completed: true)
assert :ok = Todos.send_completion(todo)
end
endOne setup line configures the mock for all tests in the describe block. No repetition, no boilerplate.
Composite Helpers
Some test scenarios need multiple services configured. Create composite helpers that combine individual helpers:
defmodule ExTest.StubHelper do
# ... individual helpers ...
def with_all_success(context \\ %{}) do
context
|> with_email_success()
|> with_storage_success()
|> with_fixed_clock()
end
def with_all_failure(context \\ %{}) do
context
|> with_email_failure()
|> with_storage_failure()
end
endThe with_all_success/1 helper chains three individual helpers together. It takes the context, pipes it through each helper, and returns the final context. Each helper in the chain configures one mock.
This pattern enables two usage styles:
Individual helpers for specific scenarios:
describe "storage operations only" do
setup :with_storage_success
test "uploads file" do
assert {:ok, url} = Storage.upload("data", "file.txt")
end
endComposite helpers for full integration scenarios:
describe "end-to-end workflow" do
setup :with_all_success
test "creates, stores, and notifies" do
# Email, storage, and clock all work
result = Todos.full_workflow(params)
assert result.success
end
endThe composite helper is itself composable. You could build with_all_success_plus_auth/1 that chains with_all_success/1 with authentication mocks. Helpers compose infinitely.
The Context Pattern
Notice every helper accepts and returns a context map. This is the key to composability. The context pattern works because:
Default parameter: context \\ %{} means you can call helpers standalone or with a context. Both with_email_success() and with_email_success(context) work.
Pass-through: Helpers return the context unchanged (or updated). This enables piping: context |> helper1() |> helper2().
ExUnit integration: ExUnit’s setup macro expects functions that accept and return context. Your helpers match this signature perfectly.
The context flows through the setup pipeline:
setup [:with_email_success, :with_storage_success, :with_todos]
# Equivalent to:
setup do
context = %{}
context = with_email_success(context) # context = %{}
context = with_storage_success(context) # context = %{}
context = with_todos(context) # context = %{todos: [todo1, todo2, todo3]}
{:ok, context}
endStub helpers don’t modify the context (they return it unchanged). Data setup helpers add keys to the context. Both patterns compose because they follow the same signature.
AssertHelper: Domain-Specific Assertions
Generic assertions like assert todo.id don’t express business meaning. Domain-specific assertions make tests readable.
Chainable Assertions
Build assertions that validate domain invariants and return the input for chaining:
defmodule ExTest.AssertHelper do
import ExUnit.Assertions
def assert_todo_valid(todo) do
assert todo.id, "Todo should have an id"
assert todo.title, "Todo should have a title"
assert todo.inserted_at, "Todo should have inserted_at"
assert todo.updated_at, "Todo should have updated_at"
todo
end
def assert_todo_completed(todo) do
assert todo.completed == true, "Expected todo to be completed"
todo
end
def assert_todo_incomplete(todo) do
assert todo.completed == false, "Expected todo to be incomplete"
todo
end
endEach assertion follows the same pattern:
Validate invariants: Multiple related assertions grouped together. assert_todo_valid/1 checks four fields. If any fail, you get a clear error message.
Clear messages: Custom messages explain what failed. “Todo should have an id” is more helpful than “Expected truthy value”.
Return input: Every assertion returns the value it received. This enables chaining.
Use these assertions to make tests expressive:
test "creating a todo" do
result = Todos.create(%{title: "New todo"})
assert_todo_valid(result)
assert_todo_incomplete(result)
endThe test reads like English: “Assert todo is valid. Assert todo is incomplete.” No ceremony, just meaning.
Chaining for Fluent APIs
Returning the input enables chained assertions:
test "completed todo is valid" do
insert(:todo, completed: true)
|> assert_todo_valid()
|> assert_todo_completed()
endThe pipe operator threads the value through multiple assertions. Each assertion validates one aspect, then passes the value along. This creates a fluent API for validation.
You can also chain with business logic:
test "complete workflow" do
Todos.create(%{title: "Task"})
|> assert_todo_valid()
|> assert_todo_incomplete()
|> Todos.complete()
|> assert_todo_completed()
endCreate a todo, validate it’s incomplete, complete it, validate it’s completed. The chain reads top-to-bottom like a story.
Custom Error Messages
Good assertions include context in their error messages:
def assert_todo_overdue(todo) do
assert todo.due_date, "Todo should have a due date"
assert Date.compare(todo.due_date, Date.utc_today()) == :lt,
"Expected due date #{todo.due_date} to be before today"
assert todo.completed == false,
"Overdue todo should not be completed"
todo
endWhen assert_todo_overdue/1 fails, you see exactly what went wrong:
Expected due date 2025-06-20 to be before today
The message includes the actual due date. You don’t need to inspect variables or add IO.puts statements. The assertion itself provides context.
Compare to a generic assertion:
# Generic - what date? what condition?
assert Date.compare(todo.due_date, Date.utc_today()) == :lt
# Specific - clear context in error
assert Date.compare(todo.due_date, Date.utc_today()) == :lt,
"Expected due date #{todo.due_date} to be before today"Custom messages make failures actionable. You know what failed and why without debugging.
When to Create Custom Assertions
Create custom assertions when:
Multiple assertions validate one concept: “Valid todo” requires checking id, title, and timestamps. Group them.
You repeat the same assertion: If you write assert todo.completed == true in 20 tests, create assert_todo_completed/1.
Domain meaning isn’t obvious: assert_todo_overdue/1 expresses business logic better than comparing dates.
Error context helps debugging: Include actual values in messages when they aid understanding.
Don’t create assertions for simple one-off checks. assert result == :ok is fine. But when validation becomes repetitive or complex, extract an assertion helper.
SetupHelper: Reusable Data Setup
Test data setup is another source of repetition. Centralize it with setup helpers.
Single Resource Helpers
Create helpers that add common data to the test context:
defmodule ExTest.SetupHelper do
import ExTest.Factory
def with_todo(context) do
todo = insert(:todo)
Map.put(context, :todo, todo)
end
def with_completed_todo(context) do
todo = insert(:todo, completed: true)
Map.put(context, :completed_todo, todo)
end
def with_overdue_todo(context) do
todo = insert(:todo,
due_date: Date.add(Date.utc_today(), -7),
completed: false
)
Map.put(context, :overdue_todo, todo)
end
endEach helper creates one resource and adds it to the context under a specific key:
with_todo/1 adds :todo key with a standard todo
with_completed_todo/1 adds :completed_todo key with a completed todo
with_overdue_todo/1 adds :overdue_todo key with an overdue todo
The key names are consistent and predictable. Tests can pattern match on the context to access data:
describe "todo operations" do
setup [:with_todo, :with_completed_todo]
test "has both todos", %{todo: todo, completed_todo: completed} do
assert_todo_incomplete(todo)
assert_todo_completed(completed)
end
endThe setup line reads like a specification: “With a todo and with a completed todo.” The test accesses them from the context.
Multiple Resource Helpers
Some tests need collections of data. Create helpers that accept options:
defmodule ExTest.SetupHelper do
def with_todos(context, opts \\ []) do
count = Keyword.get(opts, :count, 3)
todos = insert_list(count, :todo)
Map.put(context, :todos, todos)
end
def with_mixed_todos(context, opts \\ []) do
completed_count = Keyword.get(opts, :completed, 2)
incomplete_count = Keyword.get(opts, :incomplete, 2)
completed = insert_list(completed_count, :todo, completed: true)
incomplete = insert_list(incomplete_count, :todo, completed: false)
context
|> Map.put(:completed_todos, completed)
|> Map.put(:incomplete_todos, incomplete)
|> Map.put(:all_todos, completed ++ incomplete)
end
endThe with_todos/2 helper accepts a :count option. Default is 3 todos, but you can override:
setup do
with_todos(%{}, count: 5)
endThe with_mixed_todos/2 helper creates multiple collections and adds them all to context. Tests can access completed todos, incomplete todos, or all todos:
test "filters todos", %{completed_todos: completed, incomplete_todos: incomplete} do
assert_count(completed, 2)
assert_count(incomplete, 2)
endThis pattern scales to any complexity. Need users with different roles? Create with_mixed_users/2. Need todos assigned to specific users? Create with_assigned_todos/2 that takes a user as an option.
SetupHelper vs FactoryHelper
You might notice SetupHelper looks similar to FactoryHelper from Part 3. The key difference is what they return:
SetupHelper returns the context map:
def with_todo(context) do
todo = insert(:todo)
Map.put(context, :todo, todo) # Returns context
end
# Usage with setup macro
setup :with_todo
test "example", %{todo: todo} do ... endFactoryHelper returns the created resource:
def factory_completed_todo do
insert(:todo, completed: true) # Returns todo
end
# Usage inline in test
test "example" do
todo = factory_completed_todo()
...
endSetupHelper is for composable setup with the setup macro. Data goes into context and flows to all tests. FactoryHelper is for creating data inline within a single test.
Choose based on usage pattern:
- Data needed by multiple tests? SetupHelper
- Data specific to one test? FactoryHelper
- Complex setup scenario? SetupHelper
- Quick data creation? FactoryHelper
Both have their place. Often you’ll use SetupHelper in describe blocks and FactoryHelper for test-specific data.
Composable Setup in Action
Now combine all the helper types using ExUnit’s composable setup.
The setup Macro
ExUnit’s setup macro accepts a list of function names:
setup [:with_email_success, :with_todos, :with_completed_todo]ExUnit calls each function in order, threading the context through:
- Start with empty context
%{} - Call
with_email_success(%{})→ returns%{} - Call
with_todos(%{})→ returns%{todos: [...]} - Call
with_completed_todo(%{todos: [...]})→ returns%{todos: [...], completed_todo: ...} - Pass final context to tests
Each function receives the context from the previous function. Setup helpers add to the context. Stub helpers pass it through unchanged.
The final context contains all the data:
test "has all setup data", context do
assert Map.has_key?(context, :todos)
assert Map.has_key?(context, :completed_todo)
# Email mock is configured (not in context)
endPattern matching extracts specific keys:
test "uses specific data", %{todos: todos, completed_todo: completed} do
assert_count(todos, 3)
assert_todo_completed(completed)
endYou only extract what you need. Tests are self-documenting: the parameters show what data the test uses.
Combining Multiple Helpers
Mix and match helpers from different categories:
describe "full workflow tests" do
setup [:with_all_success, :with_mixed_todos]
test "processes all todos", %{all_todos: todos} do
# Stubs are configured
# Data is available
result = Todos.process_batch(todos)
result
|> assert_count(4)
|> assert_all(&(&1.processed))
end
endThis test has:
with_all_success: Stubs email, storage, and clockwith_mixed_todos: Creates completed and incomplete todosassert_countandassert_all: Domain assertions
Three helper categories working together. The test focuses entirely on business logic: “Process batch returns all todos marked as processed.”
You can also use helpers at different levels:
describe "todo management" do
# Describe-level setup applies to all tests
setup [:with_all_success, :with_todos]
test "first test", %{todos: todos} do
# Has all_success stubs and todos
end
describe "completed todos" do
# Additional setup for nested describe
setup :with_completed_todo
test "nested test", %{todos: todos, completed_todo: completed} do
# Has all_success stubs, todos, AND completed_todo
end
end
endNested describe blocks inherit setup from parent blocks. Helpers compose across nesting levels.
Order Matters
Setup functions run in the order listed:
# Order matters for dependencies
setup [:with_user, :with_todo_for_user]
def with_user(context) do
Map.put(context, :user, insert(:user))
end
def with_todo_for_user(context) do
# Depends on :user being in context
user = Map.fetch!(context, :user)
todo = insert(:todo, user_id: user.id)
Map.put(context, :todo, todo)
endThe with_todo_for_user/1 helper expects :user to exist in the context. List with_user first in the setup array.
For independent helpers, order doesn’t matter:
# These can run in any order
setup [:with_email_success, :with_storage_success, :with_clock_success]No helper depends on another, so order is irrelevant. But be explicit when dependencies exist.
Organization Tips
As your helper collection grows, organization becomes important.
File Structure
Organize helpers by category:
test/
├── support/
│ ├── stub_helper.ex # Mock configuration
│ ├── assert_helper.ex # Custom assertions
│ ├── setup_helper.ex # Data setup
│ ├── factory_helper.ex # Inline data creation
│ ├── data_case.ex # Test case template
│ ├── factory.ex # ExMachina factories
│ └── stubs/ # Stub implementations
│ ├── email_stub.ex
│ ├── storage_stub.ex
│ └── clock_stub.ex
└── ex_test/
└── (test files)
One helper type per file: Don’t mix assertions with setup logic. Separation makes helpers discoverable.
Stubs separate from helpers: Stub implementations go in stubs/ directory. Helpers that use stubs go in stub_helper.ex.
Descriptive filenames: setup_helper.ex immediately tells you what’s inside.
Importing Helpers
Import all helpers in DataCase so they’re available everywhere:
defmodule ExTest.DataCase do
use ExUnit.CaseTemplate
using do
quote do
import Ecto
import Ecto.Changeset
import Ecto.Query
import ExTest.DataCase
import ExTest.Factory
import ExTest.FactoryHelper
import ExTest.StubHelper # Stub configuration
import ExTest.AssertHelper # Custom assertions
import ExTest.SetupHelper # Reusable setup
end
end
# ... rest of DataCase ...
endNow every test that uses DataCase has access to all helpers:
defmodule ExTest.SomeTest do
use ExTest.DataCase, async: true
# StubHelper, AssertHelper, SetupHelper all available
setup [:with_email_success, :with_todos]
test "example", %{todos: todos} do
todos
|> assert_count(3)
|> assert_all(&assert_todo_valid/1)
end
endNo manual imports needed. Helpers work like they’re part of the test DSL.
Best Practices
Follow these guidelines to keep helpers maintainable.
Keep Helpers Simple
Each helper should do one thing:
# GOOD - single responsibility
def with_email_success(context) do
stub_with(ExTest.EmailMock, ExTest.Stubs.EmailStub)
context
end
# BAD - mixing concerns
def with_email_and_todos(context) do
stub_with(ExTest.EmailMock, ExTest.Stubs.EmailStub)
todos = insert_list(3, :todo)
Map.put(context, :todos, todos)
endThe bad example mixes stub configuration with data setup. This reduces flexibility. What if you want email stubs without todos?
Instead, create two focused helpers and compose them:
setup [:with_email_success, :with_todos]Now you can use each helper independently or together. Composition beats combination.
When NOT to Create Helpers
Don’t create helpers for:
One-off scenarios: If only one test needs specific setup, do it inline. Helpers add indirection. Only extract when you have repetition.
Overly generic operations: Don’t create with_database/1 that just wraps Repo.insert/1. Helpers should provide value beyond simple delegation.
Complex conditional logic: Helpers should be simple. If your helper has multiple branches or complex logic, it’s doing too much. Split it or inline it.
Test-specific assertions: assert_todo_has_title_from_params/2 is too specific. Generic helpers compose better than specialized ones.
Rule of thumb: Extract when you see the same pattern three times. Before that, duplication might be better than premature abstraction.
Documentation Through Naming
Choose descriptive names that explain intent:
Verbs for actions: with_email_success, assert_todo_valid, create_user_with_todos
Specific over generic: with_overdue_todo vs with_special_todo
Consistent prefixes: All stub helpers start with with_. All assertions start with assert_.
Good names make tests self-documenting:
setup [:with_all_success, :with_mixed_todos]
test "example", %{completed_todos: completed} do
completed
|> assert_count(2)
|> assert_all(&assert_todo_completed/1)
endEven without reading helper code, you understand what’s happening: “With all services successful and with a mix of todos, assert we have 2 completed todos and all of them are completed.”
Summary
You’ve mastered centralized test helper organization:
- Problem recognition: Test setup repetition creates maintenance burden and clutters tests
- StubHelper: Centralize mock configuration with context-accepting helpers
- Composite helpers: Combine multiple stub configurations for common scenarios
- AssertHelper: Build domain-specific assertions that return input for chaining
- SetupHelper: Create reusable data setup functions that add to context
- Composable setup: Use
setup [:helper1, :helper2]to combine helpers - Context pattern: Helpers accept and return context for composability
- Organization: Separate helpers by category and import in DataCase
- Best practices: Keep helpers simple, single-purpose, and well-named
ex-test demonstrates these patterns in action. Three helper modules provide reusable utilities for mocks, assertions, and setup. Tests compose these helpers to eliminate duplication and improve readability.
In Part 7, we’ll apply everything you’ve learned to Phoenix controller testing. Learn to test HTTP requests, responses, authentication, and error handling using all the patterns from this series.
All code examples are available in the ex-test.
Series Navigation
Previous: Part 5 - Adapter Pattern and Stubs
Next: Part 7 - Phoenix Controller Testing
All Parts
- ExUnit Fundamentals
- Database Isolation with Ecto.Sandbox
- Test Data with ExMachina
- Mocking with Mox
- Adapter Pattern and Stubs
- Centralized Test Helpers (You are here)
- Phoenix Controller Testing
