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
end

This 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
end

Each 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
end

One 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
end

The 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
end

Composite 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
end

The 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}
end

Stub 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
end

Each 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)
end

The 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()
end

The 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()
end

Create 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
end

When 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
end

Each 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
end

The 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
end

The with_todos/2 helper accepts a :count option. Default is 3 todos, but you can override:

setup do
  with_todos(%{}, count: 5)
end

The 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)
end

This 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 ... end

FactoryHelper 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()
  ...
end

SetupHelper 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:

  1. Start with empty context %{}
  2. Call with_email_success(%{}) → returns %{}
  3. Call with_todos(%{}) → returns %{todos: [...]}
  4. Call with_completed_todo(%{todos: [...]}) → returns %{todos: [...], completed_todo: ...}
  5. 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)
end

Pattern matching extracts specific keys:

test "uses specific data", %{todos: todos, completed_todo: completed} do
  assert_count(todos, 3)
  assert_todo_completed(completed)
end

You 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
end

This test has:

  • with_all_success: Stubs email, storage, and clock
  • with_mixed_todos: Creates completed and incomplete todos
  • assert_count and assert_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
end

Nested 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)
end

The 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 ...
end

Now 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
end

No 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)
end

The 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)
end

Even 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

  1. ExUnit Fundamentals
  2. Database Isolation with Ecto.Sandbox
  3. Test Data with ExMachina
  4. Mocking with Mox
  5. Adapter Pattern and Stubs
  6. Centralized Test Helpers (You are here)
  7. Phoenix Controller Testing

Resources