4ea9a884aa
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.
546 lines
16 KiB
Markdown
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.
|