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.Sandbox and 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
end

Let’s break down what’s happening:

  1. use ExTest.DataCase, async: true - Imports test helpers and enables parallel execution
  2. alias ExTest.Todos.Todo - Shortcut for the schema module
  3. @valid_attrs - Module attribute holding test data (we’ll cover this pattern in detail)
  4. describe "changeset/2" - Groups related tests logically
  5. test "description" - Individual test case
  6. assert and refute - Basic assertions

Run just this test file:

mix test test/ex_test/todos/todo_test.exs

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

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

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

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

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

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

This single line does three things:

  1. Asserts the function returns an {:ok, _} tuple (not {:error, _})
  2. Asserts the second element is a %Todo{} struct
  3. 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?
end

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

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

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

This pattern has several advantages:

  1. Visibility: Test data is at the top of the file, easy to find and modify
  2. DRY: Define once, use in many tests
  3. Compile-time: Module attributes are evaluated at compile time
  4. Named semantics: @valid_attrs, @invalid_attrs, @update_attrs clearly 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
end

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

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

Running Filtered Tests

Run only tests with a specific tag:

mix test --only todos

Exclude tests with a tag:

mix test --exclude edge_case

Combine multiple filters:

mix test --only integration --exclude slow

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

Common 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 test

Run a specific test file:

mix test test/ex_test/todos_test.exs

Run a specific test by line number:

mix test test/ex_test/todos_test.exs:61

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

Run tests with coverage:

mix test --cover

Trace test execution (shows test names as they run):

mix test --trace

Run tests with detailed failure information:

mix test --failed

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

By 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 setup and 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 @moduletag and @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

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

Resources