Testing
ExUnit testing patterns for Elixir and Phoenix applications
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 linesExUnit 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: trueout 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/0callsuser_fixture/0which callsorg_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: truefor tests with no shared mutable state. - DataCase: Wraps each test in a database transaction that rolls back, providing isolation.
- ConnCase: Provides
build_conn/0and 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: trueon every test module that does not share mutable global state. This dramatically speeds up the test suite. - Use
describeblocks to group tests by function or feature. Eachdescribecan have its ownsetup. - Write fixture modules rather than embedding factory logic in tests. Use
MyApp.XFixturesnaming convention. - Prefer testing through the public context API (e.g.,
Accounts.register_user/1) rather than reaching into implementation details. - Use
Moxwith behaviours for external dependencies. Define the behaviour, configure the mock intest.exs, andexpectorstubin each test. - Use
assert_receiveandrefute_receivefor 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
setupto 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/3or setsandbox: :sharedmode. - 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
Related Skills
Channels
Phoenix Channels and PubSub for real-time bidirectional communication
Concurrency
Elixir processes and message passing for concurrent and parallel programming
Deployment
Deploying Elixir/Phoenix applications with Mix releases, Docker, and Fly.io
Ecto
Ecto patterns for database schemas, queries, changesets, and migrations in Elixir
Genserver
GenServer patterns for stateful processes in Elixir OTP applications
Otp Supervision
OTP supervision tree design for building fault-tolerant Elixir applications