Files
elixir-patterns/patterns/testing.md
T
Aaron Weiker 4ea9a884aa docs: idiomatic Elixir and Phoenix patterns with source citations
Extracted patterns, conventions, and code smells directly from the
Elixir and Phoenix source code with file path and line number citations.

Covers: GenServer, error handling, data transforms, process design,
testing, documentation, typespecs, macros, behaviours, module organization,
Phoenix-specific patterns, framework deviations, and anti-patterns.
2026-04-29 22:50:12 -07:00

546 lines
16 KiB
Markdown

# Testing Patterns in Elixir
Patterns extracted from the Elixir standard library source code — how the core team writes and organizes tests.
---
## 1. Module-Level Async Declaration
**Source:** `lib/elixir/test/elixir/gen_server_test.exs:9`, `lib/elixir/test/elixir/enum_test.exs:8`, nearly all test files
**What it does:** Every test module declares `async: true` or `async: false` at the module level, making concurrency intent explicit.
**Why:** Tests that don't mutate global state run concurrently, dramatically speeding up the suite. The explicit opt-in forces developers to think about whether their test touches shared resources.
**Pattern:**
```elixir
defmodule GenServerTest do
use ExUnit.Case, async: true
# ...
end
# When global state is modified (e.g. registered processes):
defmodule TaskTest do
use ExUnit.Case # async defaults to false
# ...
end
```
**Key insight:** The vast majority of Elixir's own tests use `async: true`. Only tests that register global names, modify Logger config, or interact with the filesystem use synchronous mode.
---
## 2. Parameterized Tests
**Source:** `lib/elixir/test/elixir/registry_test.exs:12-22`
**What it does:** Runs the same test suite against multiple configurations using the `:parameterize` option (since v1.18).
**Why:** Avoids duplicating test modules for combinatorial configurations. The Registry needs testing with `:unique`/`:duplicate` keys and varying partition counts.
**Pattern:**
```elixir
defmodule Registry.Test do
use ExUnit.Case,
async: true,
parameterize:
for(
keys <- [:unique, :duplicate, {:duplicate, :pid}, {:duplicate, :key}],
partitions <- [1, 8],
do: %{keys: keys, partitions: partitions}
)
setup config do
name = :"#{config.test}_#{config.partitions}_#{inspect(config.keys)}"
opts = [keys: config.keys, name: name, partitions: config.partitions]
{:ok, _} = start_supervised({Registry, opts})
%{registry: name}
end
test "clean up registry on process crash", %{registry: registry, partitions: partitions} do
# Test body uses parameters from context
end
end
```
**Warning from docs:** "If you use parameterized tests and then find yourself adding conditionals in your tests to deal with different parameters, then parameterized tests may be the wrong solution."
---
## 3. Setup with `start_supervised/2`
**Source:** `lib/ex_unit/lib/ex_unit/callbacks.ex:277-340`, `lib/elixir/test/elixir/registry_test.exs:31`
**What it does:** Starts processes under a test supervisor that guarantees cleanup before the next test.
**Why:** Eliminates manual cleanup. The test supervisor terminates children in reverse order before `on_exit` callbacks run. No leaked processes between tests.
**Pattern:**
```elixir
setup config do
{:ok, _} = start_supervised({Registry, keys: :unique, name: config.test})
%{registry: config.test}
end
```
**Contrast with anti-pattern:**
```elixir
# BAD — process may leak if test crashes before cleanup
setup do
{:ok, pid} = Registry.start_link(keys: :unique, name: :my_reg)
on_exit(fn -> Process.exit(pid, :kill) end)
%{registry: :my_reg}
end
```
---
## 4. Named Setup Functions (Composable Pipelines)
**Source:** `lib/ex_unit/lib/ex_unit/callbacks.ex:100-120` (docs)
**What it does:** Defines setup as a list of named functions rather than anonymous blocks.
**Why:** Each step is independently testable, reusable, and the setup pipeline reads like a declaration of preconditions.
**Pattern:**
```elixir
setup [:clean_up_tmp_directory, :start_server, :seed_data]
defp clean_up_tmp_directory(_context), do: [tmp_dir: "/tmp/test"]
defp start_server(context), do: {:ok, server: start_supervised!({MyServer, context.tmp_dir})}
defp seed_data(context), do: :ok
```
---
## 5. `on_exit` for Reversing Global Side Effects
**Source:** `lib/elixir/test/elixir/task_test.exs:1128-1131`, `lib/logger/test/logger_test.exs:12-17`
**What it does:** Registers cleanup callbacks that always run, even if the test fails.
**Why:** Guarantees global state (Logger config, ETS tables, process registrations) is restored regardless of test outcome.
**Pattern:**
```elixir
setup do
translator = :logger.get_primary_config().filters[:logger_translator]
assert :ok = :logger.remove_primary_filter(:logger_translator)
on_exit(fn -> :logger.add_primary_filter(:logger_translator, translator) end)
end
```
**Key design:** `on_exit` runs in a *separate process* from the test, so it cannot interfere with test assertions.
---
## 6. Pattern Match Assertions
**Source:** `lib/ex_unit/lib/ex_unit/assertions.ex:145-175`
**What it does:** Uses `assert` with `=` for structural pattern matching in assertions.
**Why:** Provides rich failure messages showing both sides. More expressive than `assert x == y` when you only care about shape.
**Pattern:**
```elixir
# Assert structure, ignore specifics
assert {:ok, %{id: id}} = create_user("alice")
assert is_integer(id)
# Pin variables in patterns
x = 5
assert {:count, ^x} = get_counter()
# match? for complex guards
assert match?([%{id: id} | _] when is_integer(id), records)
```
---
## 7. `assert_receive` / `refute_receive` for Process Communication
**Source:** `lib/ex_unit/lib/ex_unit/assertions.ex:466-526`, `lib/elixir/test/elixir/process_test.exs:90-100`
**What it does:** Waits for messages matching a pattern within a timeout (default 100ms).
**Why:** Tests asynchronous process communication without `Process.sleep`. The test either receives the expected message or fails with a helpful mailbox dump.
**Pattern:**
```elixir
# Basic message assertion
test "send_after/3 sends messages once expired" do
Process.send_after(self(), :hello, 10)
assert_receive :hello
end
# Pattern matching with pins
test "monitor/2 with monitor options" do
ref_and_alias = Process.monitor(pid, alias: :explicit_unalias)
send(pid, {:ping, ref_and_alias})
assert_receive :pong
assert_receive {:DOWN, ^ref_and_alias, _, _, _}
end
# Negative assertion
test "exit(pid, :normal) does not cause the target process to exit" do
Process.exit(pid, :normal)
refute_receive {:EXIT, ^pid, :normal}, 100
end
```
---
## 8. Testing GenServers via Public API (No Internal State Inspection)
**Source:** `lib/elixir/test/elixir/gen_server_test.exs:87-106`
**What it does:** Tests GenServer behavior exclusively through `GenServer.call/cast/stop` — never peeks at internal state.
**Why:** Tests the contract, not the implementation. Internal state changes don't break tests.
**Pattern:**
```elixir
test "start_link/2, call/2 and cast/2" do
{:ok, pid} = GenServer.start_link(Stack, [:hello])
assert GenServer.call(pid, :pop) == :hello
assert GenServer.cast(pid, {:push, :world}) == :ok
assert GenServer.call(pid, :pop) == :world
assert GenServer.stop(pid) == :ok
end
```
---
## 9. `catch_exit` for Testing Process Failures
**Source:** `lib/ex_unit/lib/ex_unit/assertions.ex:950-960`, `lib/elixir/test/elixir/gen_server_test.exs:118-137`
**What it does:** Catches exit signals from linked processes for assertion, or uses `Process.flag(:trap_exit, true)` + `assert_receive {:EXIT, ...}`.
**Why:** Testing error conditions in OTP requires intercepting exit signals. The two approaches serve different needs.
**Pattern:**
```elixir
# catch_exit for synchronous exit testing
test "call/3 exit messages" do
assert catch_exit(GenServer.call(pid, :noreply, 1)) ==
{:timeout, {GenServer, :call, [pid, :noreply, 1]}}
end
# trap_exit for linked process exits
test "exits on task error" do
Process.flag(:trap_exit, true)
task = Task.async(fn -> raise "oops" end)
assert {{%RuntimeError{}, _}, {Task, :await, [^task, 5000]}} = catch_exit(Task.await(task))
end
```
---
## 10. `@tag capture_log: true` for Suppressing Expected Log Output
**Source:** `lib/elixir/test/elixir/gen_server_test.exs:114`, `lib/elixir/test/elixir/task_test.exs:10`
**What it does:** Captures log output during the test, only printing it if the test fails.
**Why:** Tests that intentionally trigger error conditions produce noisy log output. Capturing keeps the test output clean while preserving diagnostics on failure.
**Pattern:**
```elixir
# Per-test tag
@tag capture_log: true
test "call/3 exit messages" do
# This test triggers error logs — they're captured
end
# Module-level for all tests
@moduletag :capture_log
```
---
## 11. `capture_log` / `capture_io` for Content Assertions
**Source:** `lib/ex_unit/lib/ex_unit/capture_log.ex:1-50`, `lib/elixir/test/elixir/task_test.exs:1138-1150`
**What it does:** Captures log/IO output and returns it as a string for assertion.
**Why:** Tests that the right messages are logged/printed without relying on side effects.
**Pattern:**
```elixir
# capture_log for asserting log content
test "logs a terminated task" do
assert ExUnit.CaptureLog.capture_log(fn ->
ref = Process.monitor(pid)
send(pid, :go)
receive do: ({:DOWN, ^ref, _, _, _} -> :ok)
end) =~ ~r/Task .* terminating/
end
# with_io returns both result and output (since v1.13)
{result, output} = with_io(fn ->
IO.puts("a")
2 + 2
end)
assert result == 4
assert output == "a\n"
```
**Important for async tests:** Use `=~` instead of `==` for `:stderr` captures because output from other tests may interleave.
---
## 12. `describe` Blocks for Logical Grouping
**Source:** `lib/elixir/test/elixir/task_test.exs:218,272,365`, `lib/elixir/test/elixir/process_test.exs:146`
**What it does:** Groups related tests under a named describe block. Setup inside describe only applies to that group.
**Why:** Organizes tests by function/feature. Makes test output readable. Allows scoped `@describetag` and scoped setup.
**Pattern:**
```elixir
describe "await/2" do
test "exits on timeout" do
task = %Task{ref: make_ref(), owner: self(), pid: nil, mfa: {__MODULE__, :test, 1}}
assert catch_exit(Task.await(task, 0)) == {:timeout, {Task, :await, [task, 0]}}
end
test "exits on normal exit" do
task = Task.async(fn -> exit(:normal) end)
assert catch_exit(Task.await(task)) == {:normal, {Task, :await, [task, 5000]}}
end
end
```
**Constraint:** Describe blocks cannot be nested. `setup_all` cannot appear inside describe.
---
## 13. `ExUnit.CaseTemplate` for Shared Test Infrastructure
**Source:** `lib/mix/test/test_helper.exs:79-140`, `lib/logger/test/test_helper.exs:24-65`
**What it does:** Defines reusable test case templates with shared setup, helpers, and imports.
**Why:** Eliminates duplication across test modules. Provides domain-specific test DSLs.
**Pattern:**
```elixir
# In test_helper.exs
defmodule Logger.Case do
use ExUnit.CaseTemplate
using _ do
quote do
import Logger.Case
end
end
setup do
on_exit(fn ->
# Shared cleanup for all tests using this template
end)
:ok
end
def capture_log(level \\ :debug, fun) do
Logger.configure(level: level)
capture_io(:user, fn ->
fun.()
Logger.flush()
end)
after
Logger.configure(level: :debug)
end
end
# In test file:
defmodule LoggerTest do
use Logger.Case
# Gets all imports and setup from the template
end
```
---
## 14. `doctest` Integration
**Source:** `lib/ex_unit/lib/ex_unit/doc_test.ex:1-80`, `lib/elixir/test/elixir/agent_test.exs:9`
**What it does:** Generates tests from `@doc` and `@moduledoc` code examples.
**Why:** Documentation examples are always verified. Prevents docs from rotting.
**Pattern:**
```elixir
defmodule AgentTest do
use ExUnit.Case, async: true
doctest Agent
end
# Selective doctesting:
doctest Kernel, except: [===: 2, !==: 2, and: 2, or: 2]
```
---
## 15. `Process.sleep(:infinity)` as a Process Parking Pattern
**Source:** `lib/elixir/test/elixir/task_test.exs:417`, `lib/elixir/test/elixir/registry_test.exs:71`
**What it does:** Spawns processes that block forever, used as test subjects that need to exist until explicitly killed.
**Why:** Creates stable process references for testing supervision, monitoring, and registry behavior. The process stays alive until the test supervisor shuts it down.
**Pattern:**
```elixir
# Process exists solely to be registered/monitored
{:ok, task} =
Task.start(fn ->
send(parent, Registry.register(registry, key, value))
Process.sleep(:infinity)
end)
# Then kill it to test cleanup:
Process.exit(task, :kill)
assert_receive {:DOWN, ^ref, _, _, _}
```
**Important distinction:** This is NOT `Process.sleep(100)` for timing — it's an intentional "park this process" pattern where the process is always explicitly terminated by the test.
---
## 16. Helper Functions for Test-Specific Behavior
**Source:** `lib/elixir/test/elixir/task_test.exs:12-36`, `lib/elixir/test/elixir/supervisor_test.exs:278-285`
**What it does:** Defines private helper functions within test modules for common test operations.
**Why:** Keeps tests DRY without over-abstracting. Helpers like `wait_until_down`, `assert_kill`, `create_dummy_task` encapsulate recurring patterns.
**Pattern:**
```elixir
defmodule TaskTest do
use ExUnit.Case
# Helper to create a known-state task for testing edge cases
defp create_dummy_task(reason) do
{pid, ref} = spawn_monitor(Kernel, :exit, [reason])
receive do
{:DOWN, ^ref, _, _, _} ->
%Task{ref: ref, pid: pid, owner: self(), mfa: {__MODULE__, :create_dummy_task, 1}}
end
end
# Helper that properly waits for process termination
def wait_until_down(task) do
ref = Process.monitor(task.pid)
assert_receive {:DOWN, ^ref, _, _, _}
end
# Helper for asserting process kill
defp assert_kill(pid, reason) do
ref = Process.monitor(pid)
Process.exit(pid, reason)
assert_receive {:DOWN, ^ref, _, _, _}
end
end
```
---
## 17. `@tag :tmp_dir` for Filesystem Tests
**Source:** `lib/ex_unit/lib/ex_unit/case.ex:281-304`, `lib/elixir/test/elixir/path_test.exs:12`
**What it does:** ExUnit automatically creates a unique temporary directory and passes its path via the test context.
**Why:** Filesystem tests need isolation. Each test gets its own directory, removed before creation to ensure a clean slate.
**Pattern:**
```elixir
@tag :tmp_dir
test "writes files", %{tmp_dir: tmp_dir} do
path = Path.join(tmp_dir, "test.txt")
File.write!(path, "hello")
assert File.read!(path) == "hello"
end
```
---
## 18. `assert_raise` with Message Matching
**Source:** `lib/ex_unit/lib/ex_unit/assertions.ex:815-885`
**What it does:** Asserts both the exception type AND the message content (string or regex).
**Why:** Verifying the exception type alone is insufficient — the message tells users what went wrong. Testing it ensures error UX.
**Pattern:**
```elixir
# Exact message match
assert_raise ArgumentError, ~r"expected :name option to be one of the following:", fn ->
GenServer.start_link(Stack, [:hello], name: "my_gen_server_name")
end
# Regex for dynamic content
assert_raise RuntimeError, ~r/^today's lucky number is 0\.\d+!$/, fn ->
raise "today's lucky number is #{:rand.uniform()}!"
end
```
---
## 19. `@moduletag` / `@describetag` for Cross-Cutting Configuration
**Source:** `lib/elixir/test/elixir/system_test.exs:104,163`, `lib/elixir/test/elixir/task_test.exs:10`
**What it does:** Sets tags that apply to all tests in a module or describe block, used for filtering and configuration.
**Why:** Enables running subsets of tests (`mix test --include unix`) and applying configuration (like `:capture_log`) without repeating it on every test.
**Pattern:**
```elixir
defmodule SystemTest do
use ExUnit.Case, async: true
describe "Windows" do
@describetag :windows
# All tests here tagged :windows
end
describe "Unix" do
@describetag :unix
# All tests here tagged :unix
end
end
```
---
## 20. Context Pattern Matching in Test Signatures
**Source:** `lib/ex_unit/lib/ex_unit/case.ex:57-80`, `lib/elixir/test/elixir/gen_server_test.exs:166`
**What it does:** Destructures the test context directly in the test function signature.
**Why:** Makes dependencies explicit. You see exactly what each test needs from setup.
**Pattern:**
```elixir
test "abcast/3", %{test: name} do
{:ok, _} = GenServer.start_link(Stack, [], name: name)
assert GenServer.abcast(name, {:push, :hello}) == :abcast
end
# Using test name for unique naming — prevents collision in async tests
```
The `%{test: name}` pattern is ubiquitous — the test name is unique per module, making it perfect for naming registered processes in async tests.