Skip to main content
Technology & EngineeringElixir Phoenix254 lines

Testing

ExUnit testing patterns for Elixir and Phoenix applications

Quick Summary18 lines
You are an expert in testing Elixir applications with ExUnit and related tools.

## Key Points

- **ExUnit.Case**: The base test module. Use `async: true` for tests with no shared mutable state.
- **DataCase**: Wraps each test in a database transaction that rolls back, providing isolation.
- **ConnCase**: Provides `build_conn/0` and helpers for testing HTTP endpoints.
- **ChannelCase**: Provides helpers for testing Phoenix Channels.
- **Mox**: The standard library for defining and verifying mock implementations of behaviours.
- **Tags**: Metadata on tests used for filtering, setup, and configuration.
- Use `async: true` on every test module that does not share mutable global state. This dramatically speeds up the test suite.
- Use `describe` blocks to group tests by function or feature. Each `describe` can have its own `setup`.
- Write fixture modules rather than embedding factory logic in tests. Use `MyApp.XFixtures` naming convention.
- Prefer testing through the public context API (e.g., `Accounts.register_user/1`) rather than reaching into implementation details.
- Use `Mox` with behaviours for external dependencies. Define the behaviour, configure the mock in `test.exs`, and `expect` or `stub` in each test.
- Use `assert_receive` and `refute_receive` for testing asynchronous message passing.
skilldb get elixir-phoenix-skills/TestingFull skill: 254 lines
Paste into your CLAUDE.md or agent config

ExUnit Testing Patterns — Elixir/Phoenix

You are an expert in testing Elixir applications with ExUnit and related tools.

Overview

ExUnit is Elixir's built-in test framework. It provides async test execution, pattern-match-based assertions, setup/teardown callbacks, and a tag system for filtering tests. Phoenix extends this with ConnTest for controller testing, ChannelTest for channels, and DataCase for database-backed tests with sandboxed transactions.

Core Philosophy

ExUnit is built around the idea that tests should be fast, isolated, and concurrent by default. The async: true flag is not an optimization you enable later — it is the default posture you should adopt from the start and only disable when tests genuinely share mutable state. A test suite that runs sequentially because no one bothered to add async: true is leaving performance on the table for no reason.

Elixir's pattern matching makes assertions expressive and precise. Rather than asserting equality on entire data structures, you can pattern-match on just the fields you care about, ignoring the rest. This produces tests that communicate intent clearly and do not break when unrelated fields are added. The assert {:ok, %{email: "test@example.com"}} = result form is both a test and documentation of what matters in the result.

The boundary between what to mock and what to test directly should follow the application's architecture. Mock at external boundaries — HTTP clients, email services, payment providers — using Mox with explicit behaviours. Test everything inside those boundaries through the public context API. This gives you confidence that your internal code works correctly while insulating tests from the unreliability and slowness of external services.

Anti-Patterns

  • Sequential by Default: Writing test modules without async: true out of habit or caution. Sequential tests are slower and hide shared-state bugs. Start with async and only drop to synchronous when you have a specific reason (shared database state in sandbox mode, global mock configuration).

  • Testing Implementation Details: Asserting on internal function calls, private function return values, or the number of times a function was invoked instead of asserting on the observable result. These tests break on every refactor and provide no confidence that the behavior is correct.

  • Over-Mocking Internal Modules: Using Mox to mock your own context modules or internal services. This decouples tests from the actual implementation, meaning your tests can pass while the real code is broken. Mock only at the boundaries where external systems live.

  • Fixture Explosion: Creating deeply nested fixture functions where post_fixture/0 calls user_fixture/0 which calls org_fixture/0, building a chain of database records for every test. Keep fixtures minimal, make dependencies explicit, and only create what the specific test needs.

  • Ignoring Sandbox Allowances: Spawning processes in tests that access the database without calling Ecto.Adapters.SQL.Sandbox.allow/3. The spawned process uses a different database connection and either cannot see the test's data or causes unexpected failures. Always allow spawned processes to share the test's sandbox connection.

Core Concepts

  • ExUnit.Case: The base test module. Use async: true for tests with no shared mutable state.
  • DataCase: Wraps each test in a database transaction that rolls back, providing isolation.
  • ConnCase: Provides build_conn/0 and helpers for testing HTTP endpoints.
  • ChannelCase: Provides helpers for testing Phoenix Channels.
  • Mox: The standard library for defining and verifying mock implementations of behaviours.
  • Tags: Metadata on tests used for filtering, setup, and configuration.

Implementation Patterns

Basic Unit Test

defmodule MyApp.Accounts.UserTest do
  use MyApp.DataCase, async: true

  alias MyApp.Accounts.User

  describe "registration_changeset/2" do
    test "valid attributes produce a valid changeset" do
      attrs = %{email: "user@example.com", password: "secure_password_123"}
      changeset = User.registration_changeset(%User{}, attrs)

      assert changeset.valid?
      assert get_change(changeset, :email) == "user@example.com"
      assert get_change(changeset, :password_hash)
    end

    test "requires email" do
      changeset = User.registration_changeset(%User{}, %{password: "secure_password_123"})

      refute changeset.valid?
      assert %{email: ["can't be blank"]} = errors_on(changeset)
    end

    test "rejects short passwords" do
      attrs = %{email: "user@example.com", password: "short"}
      changeset = User.registration_changeset(%User{}, attrs)

      assert %{password: ["should be at least 12 character(s)"]} = errors_on(changeset)
    end
  end
end

Context / Integration Test

defmodule MyApp.AccountsTest do
  use MyApp.DataCase, async: true

  alias MyApp.Accounts

  import MyApp.AccountsFixtures

  describe "register_user/1" do
    test "creates a user with valid data" do
      attrs = %{email: "new@example.com", password: "valid_password_123"}

      assert {:ok, user} = Accounts.register_user(attrs)
      assert user.email == "new@example.com"
      assert user.password_hash
      assert is_nil(user.password)
    end

    test "returns error changeset with duplicate email" do
      existing = user_fixture()
      attrs = %{email: existing.email, password: "valid_password_123"}

      assert {:error, changeset} = Accounts.register_user(attrs)
      assert %{email: ["has already been taken"]} = errors_on(changeset)
    end
  end
end

Test Fixtures

defmodule MyApp.AccountsFixtures do
  alias MyApp.Accounts

  def unique_user_email, do: "user#{System.unique_integer()}@example.com"

  def valid_user_attributes(attrs \\ %{}) do
    Enum.into(attrs, %{
      email: unique_user_email(),
      password: "valid_password_123"
    })
  end

  def user_fixture(attrs \\ %{}) do
    {:ok, user} =
      attrs
      |> valid_user_attributes()
      |> Accounts.register_user()

    user
  end
end

Controller / ConnCase Test

defmodule MyAppWeb.PostControllerTest do
  use MyAppWeb.ConnCase, async: true

  import MyApp.ContentFixtures

  setup %{conn: conn} do
    user = MyApp.AccountsFixtures.user_fixture()
    conn = log_in_user(conn, user)
    %{conn: conn, user: user}
  end

  describe "GET /posts" do
    test "lists all posts", %{conn: conn} do
      post = post_fixture(title: "Test Post")
      conn = get(conn, ~p"/posts")

      assert html_response(conn, 200) =~ "Test Post"
    end
  end

  describe "POST /posts" do
    test "creates post with valid data", %{conn: conn} do
      conn = post(conn, ~p"/posts", post: %{title: "New", body: "Content"})

      assert redirected_to(conn) =~ ~p"/posts"
    end

    test "renders errors with invalid data", %{conn: conn} do
      conn = post(conn, ~p"/posts", post: %{title: ""})

      assert html_response(conn, 200) =~ "can't be blank"
    end
  end
end

LiveView Test

defmodule MyAppWeb.CounterLiveTest do
  use MyAppWeb.ConnCase, async: true

  import Phoenix.LiveViewTest

  test "increments counter", %{conn: conn} do
    {:ok, view, html} = live(conn, ~p"/counter")

    assert html =~ "Count: 0"

    assert view
           |> element("button", "+1")
           |> render_click() =~ "Count: 1"
  end

  test "validates form", %{conn: conn} do
    {:ok, view, _html} = live(conn, ~p"/items/new")

    assert view
           |> form("#item-form", item: %{name: ""})
           |> render_change() =~ "can't be blank"

    assert view
           |> form("#item-form", item: %{name: "Valid Name"})
           |> render_submit()

    assert_redirect(view, ~p"/items")
  end
end

Mox for Behaviour Mocking

# In test/support/mocks.ex
Mox.defmock(MyApp.HTTPClientMock, for: MyApp.HTTPClient)

# In config/test.exs
config :my_app, http_client: MyApp.HTTPClientMock

# In the test
defmodule MyApp.WeatherServiceTest do
  use ExUnit.Case, async: true

  import Mox

  setup :verify_on_exit!

  test "fetches weather data" do
    expect(MyApp.HTTPClientMock, :get, fn url ->
      assert url =~ "api.weather.com"
      {:ok, %{status: 200, body: ~s({"temp": 72})}}
    end)

    assert {:ok, %{temp: 72}} = MyApp.WeatherService.current("NYC")
  end
end

Best Practices

  • Use async: true on every test module that does not share mutable global state. This dramatically speeds up the test suite.
  • Use describe blocks to group tests by function or feature. Each describe can have its own setup.
  • Write fixture modules rather than embedding factory logic in tests. Use MyApp.XFixtures naming convention.
  • Prefer testing through the public context API (e.g., Accounts.register_user/1) rather than reaching into implementation details.
  • Use Mox with behaviours for external dependencies. Define the behaviour, configure the mock in test.exs, and expect or stub in each test.
  • Use assert_receive and refute_receive for testing asynchronous message passing.

Common Pitfalls

  • Forgetting async: true: Tests default to synchronous. Always opt in to async unless there is a specific reason not to.
  • Shared state between tests: Tests must not depend on execution order. Use setup to create fresh state for each test.
  • Over-mocking: Mocking internal modules makes tests brittle. Only mock at external boundaries (HTTP clients, email services, third-party APIs).
  • Not using the sandbox: If a test spawns a process that accesses the database, you must call Ecto.Adapters.SQL.Sandbox.allow/3 or set sandbox: :shared mode.
  • Testing implementation instead of behaviour: Assert on return values and side effects, not on how many times an internal function was called.

Install this skill directly: skilldb add elixir-phoenix-skills

Get CLI access →