From 8f606d40d734eb2d8690a18568b848ea2e148722 Mon Sep 17 00:00:00 2001 From: Aaron Weiker Date: Thu, 30 Apr 2026 05:40:11 -0700 Subject: [PATCH] docs: add when/when-not to error-handling --- patterns/error-handling.md | 906 +++++++++++++++++++++++++++ patterns/genserver.md | 1109 +++++++++++++++++++++++++++++++++ patterns/testing.md | 1181 ++++++++++++++++++++++++++++++++++++ 3 files changed, 3196 insertions(+) diff --git a/patterns/error-handling.md b/patterns/error-handling.md index b162872..5aa57fe 100644 --- a/patterns/error-handling.md +++ b/patterns/error-handling.md @@ -45,6 +45,64 @@ else end ``` +### When to Use + +**Triggers:** +- You have 2+ sequential steps that each return a value you need to pattern-match before continuing +- Each step can fail independently, and you want the error to propagate cleanly +- The caller cares about *which* step failed (different error reasons) + +**Example — before:** +```elixir +def create_user(params) do + case validate_email(params) do + :ok -> + case validate_password(params) do + :ok -> + case Repo.insert(User.changeset(params)) do + {:ok, user} -> {:ok, user} + {:error, changeset} -> {:error, changeset} + end + {:error, reason} -> {:error, reason} + end + {:error, reason} -> {:error, reason} + end +end +``` + +**Example — after:** +```elixir +def create_user(params) do + with :ok <- validate_email(params), + :ok <- validate_password(params), + {:ok, user} <- Repo.insert(User.changeset(params)) do + {:ok, user} + end +end +``` + +### When NOT to Use + +**Don't use this when:** +- You have only one fallible step (just use `case`) +- The steps are independent and don't feed into each other (use separate validations) +- You need to transform the error at each step differently + +**Over-application example:** +```elixir +# Overkill — single step doesn't need `with` +with {:ok, user} <- Repo.get_user(id) do + {:ok, user} +end +``` + +**Better alternative:** +```elixir +Repo.get_user(id) +``` + +**Why:** `with` adds cognitive overhead. When there's only one `<-` clause, it's just a more verbose `case`. Reserve `with` for genuine multi-step chains where the flattening of nested cases provides real clarity. + --- ## 2. Real-World `with` — Multi-Step Fallible Operations @@ -82,6 +140,84 @@ case :code.which(module) do # ... and so on ``` +### When to Use + +**Triggers:** +- You have 3+ steps that depend on previous results and any can fail +- The caller only cares about success vs. failure (not which step failed) +- The failure values from different steps have incompatible shapes (bare atoms, tuples, `nil`, `false`) + +**Example — before:** +```elixir +def load_config(path) do + case File.read(path) do + {:ok, contents} -> + case Jason.decode(contents) do + {:ok, json} -> + case Map.fetch(json, "database") do + {:ok, db_config} -> + case validate_db_config(db_config) do + :ok -> {:ok, db_config} + error -> error + end + :error -> {:error, :missing_database_key} + end + {:error, _} -> {:error, :invalid_json} + end + {:error, reason} -> {:error, reason} + end +end +``` + +**Example — after:** +```elixir +def load_config(path) do + with {:ok, contents} <- File.read(path), + {:ok, json} <- Jason.decode(contents), + {:ok, db_config} <- Map.fetch(json, "database"), + :ok <- validate_db_config(db_config) do + {:ok, db_config} + else + _ -> {:error, :config_load_failed} + end +end +``` + +### When NOT to Use + +**Don't use this when:** +- The caller needs to know *exactly* which step failed with a specific reason +- You need different recovery strategies for different failure points +- `else _ -> :error` would hide important diagnostic information in production code + +**Over-application example:** +```elixir +# Hides the actual failure from callers who need it +def process_payment(order) do + with {:ok, card} <- fetch_card(order.user_id), + {:ok, charge} <- charge_card(card, order.total), + {:ok, receipt} <- create_receipt(charge) do + {:ok, receipt} + else + _ -> {:error, :payment_failed} + end +end +``` + +**Better alternative:** +```elixir +# Let each error propagate with its reason +def process_payment(order) do + with {:ok, card} <- fetch_card(order.user_id), + {:ok, charge} <- charge_card(card, order.total), + {:ok, receipt} <- create_receipt(charge) do + {:ok, receipt} + end + # Each step returns {:error, :card_not_found}, {:error, :declined}, etc. +``` + +**Why:** When errors require different handling (retry payment vs notify user vs alert ops), collapsing them into `_ -> :error` removes actionable information. Use the catch-all only when the caller genuinely treats all failures the same. + --- ## 3. Another `with` — Error Info Extraction @@ -113,6 +249,75 @@ end **Why:** The stacktrace might be empty or the opts might not contain `:error_info`. Using `with` makes the "happy path" clear while gracefully falling through to `:error` for any mismatch. +### When to Use + +**Triggers:** +- You're extracting nested data from loosely-structured inputs (stacktraces, metadata maps, external API responses) +- The data might not contain what you need at any level of nesting +- A graceful "not available" result is acceptable + +**Example — before:** +```elixir +def extract_request_id(conn) do + case List.keyfind(conn.req_headers, "x-request-id", 0) do + {_, value} -> + case String.split(value, "-") do + [_prefix, id | _] -> + case Integer.parse(id) do + {num, ""} -> {:ok, num} + _ -> :error + end + _ -> :error + end + nil -> :error + end +end +``` + +**Example — after:** +```elixir +def extract_request_id(conn) do + with {_, value} <- List.keyfind(conn.req_headers, "x-request-id", 0), + [_prefix, id | _] <- String.split(value, "-"), + {num, ""} <- Integer.parse(id) do + {:ok, num} + else + _ -> :error + end +end +``` + +### When NOT to Use + +**Don't use this when:** +- Each extraction step needs specific fallback behavior (not just `:error`) +- You want to log or report which level of extraction failed +- The nested structure is well-typed and guaranteed by the type system + +**Over-application example:** +```elixir +# When you need different behavior at each failure point +def extract_user_preference(user, key) do + with %{preferences: prefs} <- user, + {:ok, value} <- Map.fetch(prefs, key) do + {:ok, value} + else + _ -> :error # Was it missing preferences entirely, or just the key? + end +end +``` + +**Better alternative:** +```elixir +def extract_user_preference(%{preferences: prefs}, key) do + Map.fetch(prefs, key) +end + +def extract_user_preference(_user, _key), do: {:error, :no_preferences} +``` + +**Why:** When different failure modes demand different responses (create default preferences vs. ignore missing key), pattern-matching function heads are clearer than `with` + catch-all. + --- ## 4. `{:ok, value}` / `:error` Convention (Map.fetch) @@ -147,6 +352,63 @@ def fetch(map, key) do end ``` +### When to Use + +**Triggers:** +- Your function has exactly one failure mode that's obvious from context +- Callers will compose this with `with` or other pattern-matching constructs +- The function is a low-level building block called frequently (allocations matter) + +**Example — before:** +```elixir +def get_env(key) do + case System.get_env(key) do + nil -> {:error, :not_set} + value -> {:ok, value} + end +end +``` + +**Example — after:** +```elixir +def fetch_env(key) do + case System.get_env(key) do + nil -> :error + value -> {:ok, value} + end +end +``` + +### When NOT to Use + +**Don't use this when:** +- Multiple distinct failure modes exist (file I/O: `:enoent`, `:eacces`, `:eisdir`) +- The caller needs to present failure reasons to users or log them +- The function crosses a boundary (API, service call) where failures need context + +**Over-application example:** +```elixir +# Bare :error hides important diagnostic info +def connect(host, port) do + case :gen_tcp.connect(host, port, []) do + {:ok, socket} -> {:ok, socket} + {:error, _reason} -> :error # Was it timeout? Refused? DNS failure? + end +end +``` + +**Better alternative:** +```elixir +def connect(host, port) do + case :gen_tcp.connect(host, port, []) do + {:ok, socket} -> {:ok, socket} + {:error, reason} -> {:error, reason} # Preserve the reason + end +end +``` + +**Why:** Stripping the reason from a multi-failure function destroys information callers need. Use bare `:error` only when there's genuinely nothing more to say. + --- ## 5. Bang Functions: Raise on Error (`fetch!` vs `fetch`) @@ -187,6 +449,60 @@ case Map.fetch(map, key) do end ``` +### When to Use + +**Triggers:** +- You're writing a library with functions that can fail, and want to provide both error-tuple and raising variants +- The caller has preconditions guaranteeing success (e.g., key was validated upstream) +- You want pipeline-friendly functions that return raw values, not tuples + +**Example — before:** +```elixir +# Caller must unwrap every time even when failure is impossible +{:ok, user} = Repo.fetch_user(session.user_id) +{:ok, org} = Repo.fetch_org(user.org_id) +render(conn, user: user, org: org) +``` + +**Example — after:** +```elixir +# Bang versions assert preconditions — crash if invariant is violated +user = Repo.fetch_user!(session.user_id) +org = Repo.fetch_org!(user.org_id) +render(conn, user: user, org: org) +``` + +### When NOT to Use + +**Don't use this when:** +- Failure is an expected, normal outcome (user input, network calls, file I/O) +- You want to handle the error and continue (show a message, retry, fallback) +- The function is called in a loop where one failure shouldn't crash the process + +**Over-application example:** +```elixir +# Bang in a loop — one bad record kills the whole batch +def process_all(records) do + Enum.map(records, fn record -> + ExternalAPI.fetch_details!(record.id) # Crashes on any network error + end) +end +``` + +**Better alternative:** +```elixir +def process_all(records) do + Enum.map(records, fn record -> + case ExternalAPI.fetch_details(record.id) do + {:ok, details} -> {:ok, details} + {:error, reason} -> {:error, record.id, reason} + end + end) +end +``` + +**Why:** Bang functions express "failure here means a bug." If failure is a normal possibility (network hiccup, missing optional data), use the non-bang version and handle the error. + --- ## 6. Exception Structure: `defexception` Fields @@ -226,6 +542,65 @@ raise MyError, "file not found: #{path}" # Caller can't extract `path` from the exception ``` +### When to Use + +**Triggers:** +- You're defining a domain-specific exception that callers might want to inspect programmatically +- The error has structured context (path, key, code, HTTP status) beyond just a message +- Different callers need different representations of the same error (human-readable vs machine-parseable) + +**Example — before:** +```elixir +defmodule PaymentError do + defexception message: "payment failed" +end + +raise PaymentError, "payment failed: card declined for order #123" +# Caller can't extract the order ID or reason programmatically +``` + +**Example — after:** +```elixir +defmodule PaymentError do + defexception [:reason, :order_id, :amount] + + @impl true + def message(%{reason: reason, order_id: id, amount: amount}) do + "payment failed for order ##{id} ($#{amount}): #{reason}" + end +end + +raise PaymentError, reason: :declined, order_id: 123, amount: "49.99" +# Callers can rescue and inspect: e.reason, e.order_id +``` + +### When NOT to Use + +**Don't use this when:** +- The error is truly a one-off with no structured context (simple assertion failure) +- No caller will ever `rescue` and inspect fields — it's always a crash +- You're wrapping a third-party error where you'd just copy their message anyway + +**Over-application example:** +```elixir +# Overkill for a simple invariant violation +defmodule InvalidStateError do + defexception [:current_state, :expected_states, :module, :function, :timestamp] + + def message(%{current_state: s, expected_states: es, module: m, function: f}) do + "#{m}.#{f} expected state in #{inspect(es)}, got #{inspect(s)}" + end +end +``` + +**Better alternative:** +```elixir +# Simple message is fine for programmer errors that always crash +raise "expected state in #{inspect(expected)}, got #{inspect(actual)}" +``` + +**Why:** If nobody will ever pattern-match on the exception fields, the boilerplate of `defexception` with multiple fields adds complexity without value. Use structured exceptions when the caller needs to make decisions based on the error content. + --- ## 7. Custom `exception/1` Callback for Ergonomic Raising @@ -260,6 +635,77 @@ end raise UnicodeConversionError, encoded: data, kind: "invalid", rest: <<0xFF>> ``` +### When to Use + +**Triggers:** +- The exception message is computed from multiple fields (not stored verbatim) +- Some fields are required and should fail loudly if omitted at raise time +- You want to accept transient data at raise time that doesn't need to be stored on the struct + +**Example — before:** +```elixir +defmodule RateLimitError do + defexception [:message, :retry_after] +end + +# Caller must build the message themselves +raise RateLimitError, + message: "Rate limited. Retry after #{seconds}s (limit: #{limit}/min)", + retry_after: seconds +``` + +**Example — after:** +```elixir +defmodule RateLimitError do + defexception [:retry_after, :limit, :message] + + def exception(opts) do + retry = Keyword.fetch!(opts, :retry_after) + limit = Keyword.fetch!(opts, :limit) + + %RateLimitError{ + retry_after: retry, + limit: limit, + message: "Rate limited. Retry after #{retry}s (limit: #{limit}/min)" + } + end +end + +# Clean raise site +raise RateLimitError, retry_after: 30, limit: 100 +``` + +### When NOT to Use + +**Don't use this when:** +- The default keyword-merge behavior is sufficient (simple exceptions with optional fields) +- The exception only has a `:message` field (use the default behavior) +- You don't need validation or computation at raise time + +**Over-application example:** +```elixir +defmodule NotFoundError do + defexception [:resource, :message] + + # Custom exception/1 that does nothing the default doesn't + def exception(opts) do + resource = Keyword.get(opts, :resource, "item") + %NotFoundError{resource: resource, message: "#{resource} not found"} + end +end +``` + +**Better alternative:** +```elixir +defmodule NotFoundError do + defexception [:resource] + + def message(%{resource: resource}), do: "#{resource} not found" +end +``` + +**Why:** The `message/1` callback already handles formatting from fields. Use custom `exception/1` only when you need validation (`fetch!`), transient inputs that don't map 1:1 to fields, or computation that can't be deferred to `message/1`. + --- ## 8. `raise` Macro Internals: Compile-Time Type Resolution @@ -297,6 +743,74 @@ end **Why:** By resolving at compile time, the generated code avoids runtime dispatch to figure out what kind of exception to create. String → RuntimeError, atom → that module's exception, struct → re-raise as-is. +### When to Use + +**Triggers:** +- You're writing a macro that processes error-related inputs and want to generate optimal code +- You need to dispatch on input types at compile time to avoid runtime overhead +- You're building a framework that wraps Elixir's error machinery + +**Example — before:** +```elixir +# Runtime dispatch on every call +defmacro my_raise(thing) do + quote do + cond do + is_binary(unquote(thing)) -> raise RuntimeError, unquote(thing) + is_atom(unquote(thing)) -> raise unquote(thing) + true -> raise unquote(thing) + end + end +end +``` + +**Example — after:** +```elixir +# Compile-time dispatch — no runtime branching +defmacro my_raise(thing) do + expanded = Macro.expand(thing, __CALLER__) + + case expanded do + msg when is_binary(msg) -> + quote do: raise(RuntimeError, message: unquote(msg)) + alias when is_atom(alias) -> + quote do: raise(unquote(alias)) + _ -> + quote do: raise(unquote(thing)) + end +end +``` + +### When NOT to Use + +**Don't use this when:** +- You're writing application code (just use `raise` normally — it already does this for you) +- The input type isn't known at compile time (dynamic user input) +- The optimization doesn't matter (error paths are cold by definition) + +**Over-application example:** +```elixir +# Don't reimplement raise's internals in your own macros +defmacro validate!(condition, module) do + quote do + unless unquote(condition) do + :erlang.error(unquote(module).exception([]), :none, error_info: %{module: Exception}) + end + end +end +``` + +**Better alternative:** +```elixir +defmacro validate!(condition, module) do + quote do + unless unquote(condition), do: raise(unquote(module)) + end +end +``` + +**Why:** Elixir's `raise` already performs compile-time optimization. Re-implementing its internals couples your code to implementation details that may change. Use `raise` directly unless you have a genuinely novel dispatch requirement. + --- ## 9. Error Normalization: Erlang → Elixir Exception Translation @@ -335,6 +849,66 @@ end **Why:** Erlang errors are bare atoms/tuples. Elixir provides rich context (the map that was accessed, the function that was called). The stacktrace inspection to extract `term` from `{:badkey, key}` errors is a particularly clever pattern — it looks at what function was on top of the stack to find the map argument. +### When to Use + +**Triggers:** +- You're wrapping an Erlang library or NIF that returns raw error tuples/atoms +- Your application interacts with OTP components that signal errors in Erlang style +- You want to provide Elixir-idiomatic error messages for foreign error shapes + +**Example — before:** +```elixir +# Raw Erlang errors leak through to users +case :crypto.hash(:sha256, data) do + result when is_binary(result) -> {:ok, result} + # Crashes with {:badarg, ...} — confusing for Elixir users +end +``` + +**Example — after:** +```elixir +def hash(algorithm, data) do + :crypto.hash(algorithm, data) +rescue + e in ErlangError -> + reraise normalize_crypto_error(e.original, algorithm, data), __STACKTRACE__ +end + +defp normalize_crypto_error(:badarg, algorithm, _data) do + %ArgumentError{message: "unsupported hash algorithm: #{inspect(algorithm)}"} +end +``` + +### When NOT to Use + +**Don't use this when:** +- You're working entirely within Elixir code that already returns proper exceptions +- The Erlang error is passed through to OTP (supervisors, GenServers) that handle it natively +- The error is never shown to users and is only logged internally + +**Over-application example:** +```elixir +# Normalizing errors that supervisors handle fine as-is +def init(_) do + case :ets.new(:my_table, [:set]) do + ref when is_reference(ref) -> {:ok, ref} + # :ets.new raises :badarg — don't catch and wrap this, + # let the supervisor handle the crash + end +end +``` + +**Better alternative:** +```elixir +# Let it crash — the supervisor will restart with backoff +def init(_) do + table = :ets.new(:my_table, [:set]) + {:ok, %{table: table}} +end +``` + +**Why:** Error normalization adds value at boundaries where humans read errors. Inside OTP supervision trees, raw crashes are fine — the supervisor handles restart. Don't normalize errors that nobody will read. + --- ## 10. `blame/2` Callback: Enriching Exceptions After the Fact @@ -361,6 +935,75 @@ end **Why:** Computing string distance for "did you mean?" is expensive. It only makes sense when displaying the error to a human (in IEx, crash reports). The `blame/2` callback is called lazily by `Exception.blame/3`, not on every raise. This keeps the hot path fast. +### When to Use + +**Triggers:** +- You can provide expensive-but-helpful context (suggestions, similar names, valid options) +- The enrichment involves computation that shouldn't happen on every raise (string distance, DB lookups) +- Your exception is commonly seen in development/debugging and benefits from extra hints + +**Example — before:** +```elixir +defmodule Router.NoRouteError do + defexception [:path, :method, :message] + + def message(%{path: path, method: method}) do + "no route found for #{method} #{path}" + end +end +# Error just says "no route" with no help +``` + +**Example — after:** +```elixir +defmodule Router.NoRouteError do + defexception [:path, :method, :available_routes, :message] + + def message(%{path: path, method: method}) do + "no route found for #{method} #{path}" + end + + def blame(%{path: path} = exception, stacktrace) do + suggestions = find_similar_routes(path) + + if suggestions != [] do + hint = "\n\nDid you mean:\n" <> Enum.map_join(suggestions, "\n", &" • #{&1}") + {%{exception | message: message(exception) <> hint}, stacktrace} + else + {%{exception | message: message(exception)}, stacktrace} + end + end +end +``` + +### When NOT to Use + +**Don't use this when:** +- The enrichment is cheap enough to always include in `message/1` +- The exception is raised in production hot paths where blame is never called +- You'd need side effects (network calls, disk I/O) in the blame callback + +**Over-application example:** +```elixir +# blame/2 that queries the database for suggestions +def blame(exception, stacktrace) do + similar = Repo.all(from u in User, where: ilike(u.name, ^"%#{exception.name}%")) + hint = "Did you mean: #{Enum.map_join(similar, ", ", & &1.name)}" + {%{exception | message: exception.message <> "\n" <> hint}, stacktrace} +end +``` + +**Better alternative:** +```elixir +# Keep blame pure — only use data already on the exception +def blame(%{name: name, available: available} = exception, stacktrace) do + hint = did_you_mean(name, available) + {%{exception | message: exception.message <> hint}, stacktrace} +end +``` + +**Why:** `blame/2` runs in the context of error formatting, potentially in a crashing process. Side effects (DB queries, HTTP calls) can fail or hang, making error reporting itself unreliable. Only use data already available on the exception or in the stacktrace. + --- ## 11. Guards for Type Dispatch in Error Handling @@ -392,6 +1035,68 @@ end **Why:** The guard `when is_map(map)` lets the BEAM dispatch directly to the right clause without entering the function body. When the same error has different handling based on the term type, guards make each path explicit and independently testable. +### When to Use + +**Triggers:** +- You have multiple function clauses handling the same error shape but with different term types +- The BEAM's pattern matching + guards can select the right clause without runtime `cond`/`case` +- Each type variant needs genuinely different handling (not just different messages) + +**Example — before:** +```elixir +def format_validation_error(field, value) do + cond do + is_binary(value) -> "#{field}: string too long (#{String.length(value)} chars)" + is_integer(value) -> "#{field}: number out of range (#{value})" + is_list(value) -> "#{field}: too many items (#{length(value)})" + true -> "#{field}: invalid value #{inspect(value)}" + end +end +``` + +**Example — after:** +```elixir +def format_validation_error(field, value) when is_binary(value) do + "#{field}: string too long (#{String.length(value)} chars)" +end + +def format_validation_error(field, value) when is_integer(value) do + "#{field}: number out of range (#{value})" +end + +def format_validation_error(field, value) when is_list(value) do + "#{field}: too many items (#{length(value)})" +end + +def format_validation_error(field, value) do + "#{field}: invalid value #{inspect(value)}" +end +``` + +### When NOT to Use + +**Don't use this when:** +- You only have one or two cases (a simple `if` or `case` is clearer) +- The dispatch logic involves conditions that guards can't express (complex comparisons, function calls) +- The different clauses share most of their logic (DRY violation) + +**Over-application example:** +```elixir +# Guards for trivially different behavior +def log_error(reason) when is_atom(reason), do: Logger.error("Error: #{reason}") +def log_error(reason) when is_binary(reason), do: Logger.error("Error: #{reason}") +def log_error(reason), do: Logger.error("Error: #{inspect(reason)}") +``` + +**Better alternative:** +```elixir +def log_error(reason) do + Logger.error("Error: #{if is_binary(reason) or is_atom(reason), do: reason, else: inspect(reason)}") +end +``` + +**Why:** When multiple guard clauses have nearly identical bodies, the guards add visual noise without meaningful dispatch. Consolidate when the bodies are the same or trivially different. + --- ## 12. The `:error` / `{:error, reason}` Convention Split @@ -424,6 +1129,70 @@ with {:ok, content} <- File.read(path), do: content # Returns {:error, :enoent} ``` +### When to Use + +**Triggers:** +- Designing a function's return type: ask "how many distinct ways can this fail?" +- If exactly one → bare `:error` +- If multiple distinguishable failures → `{:error, reason}` +- If the function already follows an OTP/standard convention → match it + +**Example — before:** +```elixir +# Inconsistent — sometimes tuple, sometimes bare +def find_user(id) do + case users[id] do + nil -> {:error, :not_found} # Only failure mode — why a tuple? + user -> {:ok, user} + end +end +``` + +**Example — after:** +```elixir +# Bare :error — lookup has one failure mode +def find_user(id) do + case users[id] do + nil -> :error + user -> {:ok, user} + end +end +``` + +### When NOT to Use + +**Don't use this when:** +- You're uncertain whether more failure modes will be added later (start with `{:error, reason}` for forward-compatibility) +- The function is part of a public API where bare `:error` would surprise users expecting a reason +- The function crosses a context boundary where "why it failed" will inevitably be asked + +**Over-application example:** +```elixir +# Bare :error for something with multiple failure modes +def authenticate(token) do + cond do + expired?(token) -> :error # Was it expired? Invalid? Revoked? + revoked?(token) -> :error + !valid_sig?(token) -> :error + true -> {:ok, decode(token)} + end +end +``` + +**Better alternative:** +```elixir +def authenticate(token) do + cond do + expired?(token) -> {:error, :expired} + revoked?(token) -> {:error, :revoked} + !valid_sig?(token) -> {:error, :invalid_signature} + true -> {:ok, decode(token)} + end +end +``` + +**Why:** When you collapse multiple distinct failures into bare `:error`, callers can't log, retry, or display meaningful feedback. Use bare `:error` only when there's genuinely one indistinguishable failure mode. + --- ## 13. `reduce_while` — Early Exit Without Exceptions @@ -459,6 +1228,65 @@ catch end ``` +### When to Use + +**Triggers:** +- You need to accumulate values from a collection but may stop early based on a condition +- The "stop" condition is a normal outcome, not an error (e.g., "found what I needed", "budget exhausted") +- You want the accumulated value at the point of stopping + +**Example — before:** +```elixir +# Awkward workaround with Enum.reduce + special accumulator +def take_until_budget(items, budget) do + {taken, _} = + Enum.reduce(items, {[], budget}, fn + _item, {taken, remaining} when remaining <= 0 -> {taken, remaining} + item, {taken, remaining} -> {[item | taken], remaining - item.cost} + end) + Enum.reverse(taken) +end +# Problem: still iterates ALL items even after budget is exhausted +``` + +**Example — after:** +```elixir +def take_until_budget(items, budget) do + items + |> Enum.reduce_while({[], budget}, fn + item, {taken, remaining} when item.cost > remaining -> + {:halt, {taken, remaining}} + item, {taken, remaining} -> + {:cont, {[item | taken], remaining - item.cost}} + end) + |> elem(0) + |> Enum.reverse() +end +``` + +### When NOT to Use + +**Don't use this when:** +- You always process the entire collection (use `Enum.reduce/3`) +- You just need to find the first matching element (use `Enum.find/2`) +- You want to filter or transform all elements (use `Enum.filter/2` or `Enum.map/2`) +- The early exit signals a true error condition that should propagate + +**Over-application example:** +```elixir +# reduce_while when Enum.find would suffice +Enum.reduce_while(users, nil, fn user, _acc -> + if user.admin?, do: {:halt, user}, else: {:cont, nil} +end) +``` + +**Better alternative:** +```elixir +Enum.find(users, & &1.admin?) +``` + +**Why:** `reduce_while` is for accumulating values with an early-exit condition. When you're just searching for one element, `Enum.find` or `Enum.find_value` is more expressive and immediately communicates intent. + --- ## 14. Three-Tier Error Strategy in Map Operations @@ -493,3 +1321,81 @@ def get_config(key) do Map.fetch!(config, key) end ``` + +### When to Use + +**Triggers:** +- You're designing a module with lookup/access operations that can fail +- Callers will have different error-handling needs (some compose, some assert, some use defaults) +- You want an API that guides users toward the right error strategy for their context + +**Example — before:** +```elixir +# Only one way to access — forces awkward workarounds +defmodule Cache do + def get(key) do + case :ets.lookup(:cache, key) do + [{^key, value}] -> value + [] -> nil # Callers can't distinguish "key missing" from "stored nil" + end + end +end +``` + +**Example — after:** +```elixir +defmodule Cache do + # Tier 1: Default value (config, optional data) + def get(key, default \\ nil) do + case fetch(key) do + {:ok, value} -> value + :error -> default + end + end + + # Tier 2: Composable (with chains, conditional logic) + def fetch(key) do + case :ets.lookup(:cache, key) do + [{^key, value}] -> {:ok, value} + [] -> :error + end + end + + # Tier 3: Assertion (key must exist, crash otherwise) + def fetch!(key) do + case fetch(key) do + {:ok, value} -> value + :error -> raise KeyError, key: key, term: :cache + end + end +end +``` + +### When NOT to Use + +**Don't use this when:** +- The module only has one natural access pattern (no need to force three variants) +- The operation always succeeds by design (no failure mode to tier) +- You're writing internal/private functions where only one caller exists + +**Over-application example:** +```elixir +# Three tiers for a function with no meaningful "default" behavior +defmodule PasswordHasher do + def hash(password), do: Bcrypt.hash_pwd_salt(password) + def hash!(password), do: Bcrypt.hash_pwd_salt(password) # Same thing? + + def try_hash(password) do + {:ok, Bcrypt.hash_pwd_salt(password)} # Never fails — useless tuple + end +end +``` + +**Better alternative:** +```elixir +defmodule PasswordHasher do + def hash(password), do: Bcrypt.hash_pwd_salt(password) +end +``` + +**Why:** The three-tier pattern only makes sense when failure is a real possibility and different callers genuinely need different responses to that failure. Don't cargo-cult it onto functions that always succeed or have a single calling context. diff --git a/patterns/genserver.md b/patterns/genserver.md index 8b13789..f2cff8b 100644 --- a/patterns/genserver.md +++ b/patterns/genserver.md @@ -1 +1,1110 @@ +# GenServer Patterns — From the Elixir Source +Analysis of `lib/elixir/lib/gen_server.ex`, `lib/elixir/lib/agent.ex`, and related modules. + +--- + +## Pattern 1: Client/Server API Separation + +**Source:** `lib/elixir/lib/gen_server.ex:101-149` (documentation example) + +**What it does:** Every GenServer module defines two distinct API layers — a **client API** (thin public functions that wrap `GenServer.call/cast`) and a **server API** (callback implementations). The client functions live in the same module but are clearly separated with comments. + +**Why:** Encapsulation. Callers don't need to know the message protocol. The client API provides a typed, documented interface while the server callbacks handle the actual logic. This allows changing the internal message format without breaking callers. + +**Anti-pattern:** Calling `GenServer.call(MyServer, :some_msg)` directly from other modules. This leaks the message protocol and couples callers to implementation details. + +**Code example from source:** +```elixir +defmodule Stack do + use GenServer + + # Client + + def start_link(default) when is_binary(default) do + GenServer.start_link(__MODULE__, default) + end + + def push(pid, element) do + GenServer.cast(pid, {:push, element}) + end + + def pop(pid) do + GenServer.call(pid, :pop) + end + + # Server (callbacks) + + @impl true + def init(elements) do + initial_state = String.split(elements, ",", trim: true) + {:ok, initial_state} + end + + @impl true + def handle_call(:pop, _from, state) do + [to_caller | new_state] = state + {:reply, to_caller, new_state} + end + + @impl true + def handle_cast({:push, element}, state) do + new_state = [element | state] + {:noreply, new_state} + end +end +``` + +### When to Use + +**Triggers:** You're writing a GenServer that other modules will interact with. You have more than one message type. The module will be maintained by people who didn't write it. + +**Example — before:** +```elixir +# Other modules call GenServer directly with raw messages +GenServer.call(Cache, {:get, key}) +GenServer.cast(Cache, {:put, key, value}) +GenServer.call(Cache, {:delete, key}) +``` + +**Example — after:** +```elixir +# Clean client API hides the message protocol +defmodule Cache do + use GenServer + + # Client + def get(key), do: GenServer.call(__MODULE__, {:get, key}) + def put(key, value), do: GenServer.cast(__MODULE__, {:put, key, value}) + def delete(key), do: GenServer.call(__MODULE__, {:delete, key}) + + # Server + @impl true + def handle_call({:get, key}, _from, state), do: {:reply, Map.get(state, key), state} + @impl true + def handle_cast({:put, key, value}, state), do: {:noreply, Map.put(state, key, value)} + @impl true + def handle_call({:delete, key}, _from, state), do: {:reply, :ok, Map.delete(state, key)} +end +``` + +### When NOT to Use + +**Don't use this when:** You're writing a one-off GenServer used only internally by its own supervision tree (e.g., a private worker started by a DynamicSupervisor where no external module ever sends it messages directly). + +**Over-application example:** +```elixir +# Overkill: wrapping a single internal message with a client function nobody else calls +defmodule MyApp.InternalWorker do + use GenServer + + # "Client API" that only init/1 of THIS module calls + def do_internal_thing(pid), do: GenServer.cast(pid, :do_thing) + + @impl true + def init(_) do + do_internal_thing(self()) # calling your own wrapper? + {:ok, %{}} + end + + @impl true + def handle_cast(:do_thing, state), do: {:noreply, state} +end +``` + +**Better alternative:** If the only caller is the module itself (self-messages), use `send/2` or `handle_continue` directly — no wrapper needed. + +**Why:** The client/server separation exists to decouple external callers from message formats. If there are no external callers, the abstraction adds noise without value. + +--- + +## Pattern 2: `@impl true` Annotations on All Callbacks + +**Source:** `lib/elixir/lib/gen_server.ex:41-60` (Stack example) + +**What it does:** Every callback function is annotated with `@impl true`. This tells the compiler to verify that the function is a valid callback for the declared behaviour. + +**Why:** Catches typos and mismatches at compile time. If you accidentally name a callback `handle_calls` instead of `handle_call`, the compiler will warn you. It also serves as documentation for readers — you immediately know which functions are callbacks vs. helper functions. + +**Anti-pattern:** Omitting `@impl true` on callbacks, especially when mixing callbacks with private helpers in the same module. + +**Code example from source:** +```elixir +@impl true +def init(counter) do + {:ok, counter} +end + +@impl true +def handle_call(:get, _from, counter) do + {:reply, counter, counter} +end +``` + +### When to Use + +**Triggers:** Any module that implements a behaviour (`GenServer`, `Supervisor`, `Plug`, `Phoenix.LiveView`, custom behaviours). You have a mix of callback functions and helper functions in the same module. + +**Example — before:** +```elixir +defmodule MyWorker do + use GenServer + + def init(state), do: {:ok, state} + def handle_call(:status, _from, state), do: {:reply, state, state} + def format_status(state), do: "Running: #{inspect(state)}" # Is this a callback? +end +``` + +**Example — after:** +```elixir +defmodule MyWorker do + use GenServer + + @impl true + def init(state), do: {:ok, state} + + @impl true + def handle_call(:status, _from, state), do: {:reply, state, state} + + # Clearly NOT a callback — just a private helper + defp format_status(state), do: "Running: #{inspect(state)}" +end +``` + +### When NOT to Use + +**Don't use this when:** You're implementing functions that happen to share a name with a callback but are NOT implementing that behaviour's contract (extremely rare in practice — this pattern should be used almost universally). + +**Over-application example:** +```elixir +# This is fine but misleading — the module doesn't use GenServer +defmodule MyLib.Utils do + @impl true # ERROR: no behaviour declared! + def init(config), do: Map.merge(@defaults, config) +end +``` + +**Better alternative:** Only use `@impl true` when the module declares `use GenServer` (or `@behaviour SomeBehaviour`). If your function just happens to be named `init`, that's fine — don't annotate it. + +**Why:** `@impl true` only makes sense in the context of a declared behaviour. Using it without one causes a compiler warning, not a benefit. + +--- + +## Pattern 3: Guard-Protected `start_link` + +**Source:** `lib/elixir/lib/gen_server.ex:101` and `lib/elixir/lib/agent.ex:28` + +**What it does:** The `start_link` function uses guards to validate its arguments at the API boundary, failing fast with a clear error before any process spawning occurs. + +**Why:** Catches invalid arguments immediately at the caller site, not deep inside `init/1` where the error would be harder to trace. This is the fail-fast principle applied at process boundaries. + +**Anti-pattern:** Accepting any term in `start_link` and then crashing inside `init/1` with a confusing `FunctionClauseError` or `MatchError`. + +**Code example from source:** +```elixir +# From gen_server.ex documentation +def start_link(default) when is_binary(default) do + GenServer.start_link(__MODULE__, default) +end + +# From agent.ex:246 +@spec start_link((-> term), GenServer.options()) :: on_start +def start_link(fun, options \\ []) when is_function(fun, 0) do + GenServer.start_link(Agent.Server, fun, options) +end +``` + +### When to Use + +**Triggers:** Your `start_link` accepts arguments that have a specific expected type or shape. The GenServer is part of a supervision tree where a bad argument would cascade into confusing crash reports. + +**Example — before:** +```elixir +def start_link(opts) do + GenServer.start_link(__MODULE__, opts) +end + +# Crashes deep in init with: ** (KeyError) key :port not found +@impl true +def init(opts) do + {:ok, socket} = :gen_tcp.listen(opts[:port], []) + {:ok, %{socket: socket}} +end +``` + +**Example — after:** +```elixir +def start_link(opts) when is_list(opts) do + unless Keyword.has_key?(opts, :port), do: raise(ArgumentError, ":port is required") + GenServer.start_link(__MODULE__, opts, Keyword.take(opts, [:name])) +end +``` + +### When NOT to Use + +**Don't use this when:** The argument is already validated by the supervision tree (e.g., hardcoded in `children` list), or when the argument type is so generic that a guard adds no value. + +**Over-application example:** +```elixir +# Pointless guard — any term is valid state +def start_link(initial_state) when is_map(initial_state) or is_list(initial_state) or is_integer(initial_state) do + GenServer.start_link(__MODULE__, initial_state) +end +``` + +**Better alternative:** If any term is valid initial state, just accept it without a guard. Guards should encode meaningful constraints, not enumerate all possible types. + +**Why:** Guards that don't actually constrain anything give false confidence. The goal is to catch real mistakes early, not to prove you know about guards. + +--- + +## Pattern 4: `handle_continue` for Post-Init Work + +**Source:** `lib/elixir/lib/gen_server.ex:520-528` (callback spec), `lib/elixir/lib/gen_server.ex:714-720` (handle_continue callback definition) + +**What it does:** `init/1` can return `{:ok, state, {:continue, continue_arg}}`, which causes `handle_continue/2` to be invoked immediately after init completes. This allows splitting initialization into a synchronous part (that unblocks the supervisor) and an asynchronous continuation. + +**Why:** `init/1` blocks the supervisor — if it does expensive work (DB connections, HTTP calls, cache warming), it delays the entire supervision tree startup. `handle_continue` lets you return quickly from `init/1` while still performing setup before handling any client messages. Unlike `send(self(), :continue)`, it's guaranteed to execute before any other messages in the mailbox. + +**Anti-pattern:** Doing slow initialization directly in `init/1`, blocking the supervisor. Or using `send(self(), :init_continue)` which doesn't guarantee ordering — a client message could arrive first. + +**Code example from source (callback spec):** +```elixir +@callback handle_continue(continue_arg, state :: term) :: + {:noreply, new_state} + | {:noreply, new_state, timeout | :hibernate | {:continue, continue_arg}} + | {:stop, reason :: term, new_state} + when new_state: term, continue_arg: term +``` + +**Usage pattern:** +```elixir +def init(args) do + # Quick setup only — return immediately to unblock supervisor + {:ok, %{config: args, data: nil}, {:continue, :load_data}} +end + +@impl true +def handle_continue(:load_data, state) do + # Expensive work happens here, guaranteed before any call/cast + data = expensive_database_query() + {:noreply, %{state | data: data}} +end +``` + +### When to Use + +**Triggers:** Your `init/1` needs to do work that takes more than a few milliseconds (network calls, file I/O, large computations). Your process is part of a supervision tree where blocking init delays other children. You need guaranteed execution order before any client messages arrive. + +**Example — before:** +```elixir +@impl true +def init(config) do + # This blocks the supervisor for 2+ seconds + {:ok, conn} = Database.connect(config.db_url) + schema = Database.load_schema(conn) + cache = warm_cache(conn, schema) + {:ok, %{conn: conn, schema: schema, cache: cache}} +end +``` + +**Example — after:** +```elixir +@impl true +def init(config) do + {:ok, %{config: config, conn: nil, ready: false}, {:continue, :connect}} +end + +@impl true +def handle_continue(:connect, state) do + {:ok, conn} = Database.connect(state.config.db_url) + schema = Database.load_schema(conn) + cache = warm_cache(conn, schema) + {:noreply, %{state | conn: conn, schema: schema, cache: cache, ready: true}} +end +``` + +### When NOT to Use + +**Don't use this when:** Your init is already fast (< 1ms), or when the process MUST be fully initialized before it can handle any messages (and callers expect this). + +**Over-application example:** +```elixir +@impl true +def init(count) do + # This is instant — no reason for handle_continue + {:ok, count, {:continue, :setup}} +end + +@impl true +def handle_continue(:setup, count) do + # "setup" is just... assigning a variable + {:noreply, count + 1} +end +``` + +**Better alternative:** Just do the work in `init/1` directly: `{:ok, count + 1}`. Reserve `handle_continue` for genuinely expensive operations. + +**Why:** `handle_continue` adds complexity (another callback, intermediate state where `ready: false`). If init is already fast, you're adding indirection for no benefit and making the code harder to follow. + +--- + +## Pattern 5: Timeout-Based Idle Shutdown + +**Source:** `lib/elixir/lib/gen_server.ex:335-380` + +**What it does:** Callbacks can return a timeout value (milliseconds) as the last element of the return tuple. If no message arrives within that time, `handle_info(:timeout, state)` is called. This enables idle process cleanup. + +**Why:** Prevents resource leaks from idle processes. A process that's not being used can shut itself down gracefully, freeing memory and reducing the supervision tree. Useful for connection pools, caches, and session processes. + +**Anti-pattern:** Using `Process.send_after` for idle detection — it doesn't reset on activity. The built-in timeout resets every time a message arrives, which is the correct semantic for "idle detection." + +**Code example from source:** +```elixir +defmodule Counter do + use GenServer + + @timeout to_timeout(second: 5) + + @impl true + def init(count) do + {:ok, count, @timeout} + end + + @impl true + def handle_call(:increment, _from, count) do + new_count = count + 1 + {:reply, new_count, new_count, @timeout} + end + + @impl true + def handle_info(:timeout, count) do + {:stop, :normal, count} + end +end +``` + +### When to Use + +**Triggers:** You have dynamically-spawned processes (per-user sessions, per-connection workers, cache entries) that should clean up when idle. The process holds resources (memory, connections, file handles) that shouldn't persist indefinitely. You want automatic backpressure — fewer active processes when load drops. + +**Example — before:** +```elixir +# Session processes live forever, even if the user left hours ago +defmodule UserSession do + use GenServer + + @impl true + def init(user_id) do + {:ok, load_user_data(user_id)} + end + # ... never shuts down, memory grows forever +end +``` + +**Example — after:** +```elixir +defmodule UserSession do + use GenServer + @idle_timeout :timer.minutes(30) + + @impl true + def init(user_id) do + {:ok, load_user_data(user_id), @idle_timeout} + end + + @impl true + def handle_call(:get_profile, _from, state) do + {:reply, state.profile, state, @idle_timeout} + end + + @impl true + def handle_info(:timeout, state) do + {:stop, :normal, state} + end +end +``` + +### When NOT to Use + +**Don't use this when:** The process is a singleton (only one instance, always needed). The process does periodic work that resets the timeout unintentionally. You need the timeout to fire at exact intervals regardless of activity (use `Process.send_after` instead). + +**Over-application example:** +```elixir +defmodule MyApp.DatabasePool do + use GenServer + @timeout :timer.minutes(5) + + @impl true + def init(_) do + {:ok, connect_to_db(), @timeout} + end + + @impl true + def handle_info(:timeout, state) do + # Bad: shutting down the DB pool because it was "idle" + # In reality, no queries for 5 min is normal at night + {:stop, :normal, state} + end +end +``` + +**Better alternative:** Singleton infrastructure processes (DB pools, PubSub, registries) should be `:permanent` and never self-terminate. Idle shutdown is for ephemeral, per-entity processes. + +**Why:** The timeout resets on ANY message, including internal ones (monitors, system messages). For singleton processes, self-termination causes supervisor restarts and connection churn. Idle shutdown is a resource-management pattern for dynamic process pools, not for core infrastructure. + +--- + +## Pattern 6: Periodic Work via `Process.send_after` + +**Source:** `lib/elixir/lib/gen_server.ex:298-332` (Periodically example in docs) + +**What it does:** A GenServer schedules periodic work by sending itself a message via `Process.send_after` in `init/1`, then rescheduling in `handle_info`. This creates a self-sustaining periodic loop. + +**Why:** Unlike timeouts (which reset on any message), `send_after` gives precise control over scheduling intervals. The pattern is self-healing — if the process crashes and restarts, `init` reschedules automatically. It's simpler than external timer libraries. + +**Anti-pattern:** Using `:timer.send_interval` — if the GenServer is restarted by its supervisor, the timer from the old process keeps firing into the void (the timer is tied to the process that created it, but `send_interval` isn't linked to the destination). `send_after` in `handle_info` naturally dies with the process. + +**Code example from source:** +```elixir +defmodule MyApp.Periodically do + use GenServer + + def start_link(_) do + GenServer.start_link(__MODULE__, %{}) + end + + @impl true + def init(state) do + schedule_work() + {:ok, state} + end + + @impl true + def handle_info(:work, state) do + # Do the desired work here + schedule_work() + {:noreply, state} + end + + defp schedule_work do + Process.send_after(self(), :work, 2 * 60 * 60 * 1000) + end +end +``` + +### When to Use + +**Triggers:** You need work to happen at regular intervals (health checks, cache expiry sweeps, metrics collection, heartbeats). The interval should be consistent regardless of other message traffic. The work is tied to the lifecycle of a specific process. + +**Example — before:** +```elixir +# Using :timer.send_interval — breaks on restart +defmodule MetricsCollector do + use GenServer + + @impl true + def init(state) do + :timer.send_interval(30_000, :collect) + {:ok, state} + end + + # After supervisor restart, old timer still fires + new timer starts = double collection +end +``` + +**Example — after:** +```elixir +defmodule MetricsCollector do + use GenServer + @interval :timer.seconds(30) + + @impl true + def init(state) do + schedule_collection() + {:ok, state} + end + + @impl true + def handle_info(:collect, state) do + metrics = gather_metrics() + push_to_backend(metrics) + schedule_collection() + {:noreply, %{state | last_collected: DateTime.utc_now()}} + end + + defp schedule_collection, do: Process.send_after(self(), :collect, @interval) +end +``` + +### When NOT to Use + +**Don't use this when:** The "periodic" work is actually idle detection (use GenServer timeout instead). You need sub-millisecond precision (use `:erlang.start_timer` with absolute references). The periodic task is independent of any process state (use a simple `Task` or external scheduler). + +**Over-application example:** +```elixir +defmodule MyApp.DailyReport do + use GenServer + + @impl true + def init(_) do + schedule_report() + {:ok, %{}} + end + + @impl true + def handle_info(:generate_report, state) do + # Runs once per day, holds no state, doesn't need to be a GenServer + Reports.generate_daily() + schedule_report() + {:noreply, state} + end + + defp schedule_report, do: Process.send_after(self(), :generate_report, :timer.hours(24)) +end +``` + +**Better alternative:** Use a dedicated job scheduler (Oban, Quantum) for cron-like work. Or a simple `Task` supervised by `Task.Supervisor`. A GenServer that holds no meaningful state and just fires a function periodically is over-engineered. + +**Why:** `Process.send_after` drifts over time (interval doesn't account for execution time). It doesn't handle "run at 9am daily" semantics. A stateless periodic GenServer is just a worse cron. Use the right tool for the job. + +--- + +## Pattern 7: Call vs Cast Decision (Synchronous vs Asynchronous) + +**Source:** `lib/elixir/lib/gen_server.ex:83-90` (docs), `lib/elixir/lib/agent.ex:368-378` (Agent.update uses call, Agent.cast uses cast) + +**What it does:** The Elixir team uses `call` (synchronous) for operations where the client needs confirmation or a return value, and `cast` (fire-and-forget) only when the client genuinely doesn't care about the outcome. + +**Why:** `cast` provides no backpressure — if a producer sends casts faster than the GenServer can process them, the mailbox grows unbounded until OOM. `call` naturally provides backpressure because the caller blocks. The Agent module makes this explicit: `Agent.update/3` uses `GenServer.call` (not cast!) because updates need confirmation of ordering. + +**Anti-pattern:** Using `cast` for operations that should have backpressure, or using `cast` when you actually need to know if the operation succeeded. The Agent source proves this — even "fire and forget" `Agent.update` is a `call`: + +**Code example from source (agent.ex):** +```elixir +# Agent.update uses call — NOT cast — for backpressure +@spec update(agent, (state -> state), timeout) :: :ok +def update(agent, fun, timeout \\ 5000) when is_function(fun, 1) do + GenServer.call(agent, {:update, fun}, timeout) +end + +# Agent.cast is the explicit fire-and-forget variant +@spec cast(agent, (state -> state)) :: :ok +def cast(agent, fun) when is_function(fun, 1) do + GenServer.cast(agent, {:cast, fun}) +end +``` + +### When to Use + +**Triggers:** You're deciding between `call` and `cast` for a new API function. The operation modifies state that callers depend on. You need ordering guarantees. The producer could potentially overwhelm the consumer. + +**Example — before:** +```elixir +# Using cast for writes — no backpressure, no error feedback +def enqueue(queue, item) do + GenServer.cast(queue, {:enqueue, item}) +end +# Producer sends 1M items/sec, GenServer processes 100/sec → OOM +``` + +**Example — after:** +```elixir +# Using call — producer is naturally throttled +def enqueue(queue, item) do + GenServer.call(queue, {:enqueue, item}) +end +# Producer blocks until GenServer processes each item → natural backpressure +``` + +### When NOT to Use + +**Don't use this when:** You genuinely don't need confirmation AND the producer rate is naturally bounded (e.g., logging telemetry events from a finite number of processes, or broadcasting notifications where delivery is best-effort). + +**Over-application example:** +```elixir +# Overkill: using call for fire-and-forget telemetry +def report_metric(collector, metric) do + # The caller blocks waiting for :ok, but doesn't use the reply for anything + # If the collector is slow, it slows down the entire hot path + GenServer.call(collector, {:report, metric}) +end +``` + +**Better alternative:** For telemetry/logging where you have bounded producers and losing a message isn't catastrophic, `cast` (or even `:telemetry.execute/3`) is appropriate. The key question is: "Does the caller's correctness depend on this operation completing?" + +**Why:** `call` adds latency to the caller. For hot paths where the reply is always `:ok` and the caller ignores it, you're paying synchronization cost for nothing. But this is the exception — default to `call` and only use `cast` when you've consciously evaluated the tradeoffs. + +--- + +## Pattern 8: Default Callback Implementations with Clear Error Messages + +**Source:** `lib/elixir/lib/gen_server.ex:902-993` (`__using__` macro) + +**What it does:** `use GenServer` injects default implementations of `handle_call`, `handle_cast`, `handle_info`, `terminate`, and `code_change`. The defaults for `handle_call` and `handle_cast` raise with a descriptive error message including the process name. `handle_info` logs an error. All are `defoverridable`. + +**Why:** This is defensive programming at the framework level. Instead of getting a cryptic `:function_clause` error from the Erlang runtime, developers get "attempted to call GenServer #{inspect(proc)} but no handle_call/3 clause was provided". The defaults also mean you only implement the callbacks you actually need. + +**Anti-pattern:** Not implementing `handle_info` when your process might receive unexpected messages (monitors, nodedown, etc). The default logs a warning but doesn't crash — this is intentional. + +**Code example from source:** +```elixir +@doc false +def handle_call(msg, _from, state) do + proc = + case Process.info(self(), :registered_name) do + {_, []} -> self() + {_, name} -> name + end + + case :erlang.phash2(1, 1) do + 0 -> + raise "attempted to call GenServer #{inspect(proc)} but no handle_call/3 clause was provided" + 1 -> + {:stop, {:bad_call, msg}, state} + end +end + +@doc false +def handle_info(msg, state) do + proc = + case Process.info(self(), :registered_name) do + {_, []} -> self() + {_, name} -> name + end + + :logger.error(...) + {:noreply, state} +end +``` + +### When to Use + +**Triggers:** You're relying on `use GenServer` (which you should always do rather than `@behaviour GenServer` alone). You have a GenServer that uses monitors, timers, or receives system messages you want to handle explicitly rather than getting log noise. + +**Example — before:** +```elixir +defmodule ConnectionManager do + use GenServer + + @impl true + def init(config) do + ref = Process.monitor(config.target_pid) + {:ok, %{ref: ref, target: config.target_pid}} + end + + # Forgot to handle :DOWN — default handle_info logs error but doesn't reconnect + # Process just silently loses track of the monitored process +end +``` + +**Example — after:** +```elixir +defmodule ConnectionManager do + use GenServer + + @impl true + def init(config) do + ref = Process.monitor(config.target_pid) + {:ok, %{ref: ref, target: config.target_pid}} + end + + @impl true + def handle_info({:DOWN, ref, :process, pid, reason}, %{ref: ref} = state) do + # Explicitly handle the monitor message + {:noreply, reconnect(state)} + end + + @impl true + def handle_info(_unexpected, state) do + # Catch-all: don't let the default log noise obscure real issues + {:noreply, state} + end +end +``` + +### When NOT to Use + +**Don't use this when:** N/A — this pattern is built into `use GenServer` and is always active. The real question is whether to override the defaults. + +**Over-application example:** +```elixir +# Unnecessary: explicitly reimplementing the exact same default behavior +@impl true +def handle_info(msg, state) do + require Logger + Logger.error("Unexpected message: #{inspect(msg)}") + {:noreply, state} +end +``` + +**Better alternative:** Only override defaults when you need different behavior (like silencing known-harmless messages, or handling specific info messages). If the default behavior is what you want, leave it alone. + +**Why:** Reimplementing defaults adds code that must be maintained and tested. The framework defaults are well-tested and include process-name introspection. Override when you need different behavior, not to prove you're thorough. + +--- + +## Pattern 9: `child_spec/1` Generation and Customization via `use` Options + +**Source:** `lib/elixir/lib/gen_server.ex:900-921`, `lib/elixir/lib/agent.ex:206-218`, `lib/elixir/lib/task.ex:282-292` + +**What it does:** Each `use GenServer/Agent/Task` generates a `child_spec/1` function with sensible defaults that can be customized via options passed to `use`. The child spec is a map with `:id`, `:start`, `:restart`, `:shutdown`, and `:type`. + +**Why:** Encapsulates supervision configuration within the module itself. Supervisors just need `{MyModule, arg}` — they don't need to know restart strategies or shutdown timeouts. Different modules have different defaults: GenServer/Agent default to `:permanent` restart, Task defaults to `:temporary`. + +**Anti-pattern:** Defining child specs in the supervisor module rather than in the child module. This scatters configuration and makes modules non-portable between supervision trees. + +**Code example from source:** +```elixir +# GenServer child_spec — restart: :permanent (default) +def child_spec(init_arg) do + default = %{ + id: __MODULE__, + start: {__MODULE__, :start_link, [init_arg]} + } + Supervisor.child_spec(default, unquote(Macro.escape(opts))) +end + +# Task child_spec — restart: :temporary (tasks don't restart) +def child_spec(arg) do + %{ + id: Task, + start: {Task, :start_link, [arg]}, + restart: :temporary + } +end +``` + +### When to Use + +**Triggers:** Your module will be placed in a supervision tree. You need non-default restart strategy, shutdown timeout, or id. You want the module to be self-describing (portable between supervisors). + +**Example — before:** +```elixir +# Child spec defined in the supervisor — not portable +defmodule MyApp.Supervisor do + use Supervisor + + def init(_) do + children = [ + %{ + id: MyApp.Cache, + start: {MyApp.Cache, :start_link, [[ttl: 300]]}, + restart: :transient, + shutdown: 30_000 + } + ] + Supervisor.init(children, strategy: :one_for_one) + end +end +``` + +**Example — after:** +```elixir +# Child spec encapsulated in the module itself +defmodule MyApp.Cache do + use GenServer, restart: :transient, shutdown: 30_000 + + def start_link(opts), do: GenServer.start_link(__MODULE__, opts, name: __MODULE__) + # ... +end + +# Supervisor is clean +defmodule MyApp.Supervisor do + use Supervisor + + def init(_) do + children = [{MyApp.Cache, [ttl: 300]}] + Supervisor.init(children, strategy: :one_for_one) + end +end +``` + +### When NOT to Use + +**Don't use this when:** You have multiple instances of the same module with different restart/shutdown requirements in different supervision trees (rare but possible). + +**Over-application example:** +```elixir +# Overriding child_spec when defaults are fine +defmodule SimpleCounter do + use GenServer + + # Unnecessary: these ARE the defaults + def child_spec(arg) do + %{ + id: __MODULE__, + start: {__MODULE__, :start_link, [arg]}, + restart: :permanent, + shutdown: 5000, + type: :worker + } + end +end +``` + +**Better alternative:** Just `use GenServer` — the generated `child_spec/1` already has these exact defaults. Only override or pass options when you need something different. + +**Why:** Manually writing `child_spec/1` that matches the auto-generated one is dead code. It adds maintenance burden and risks diverging from the actual defaults if they ever change. + +--- + +## Pattern 10: Agent as Minimal State Wrapper (GenServer Under the Hood) + +**Source:** `lib/elixir/lib/agent.ex:1-60` (module docs), `lib/elixir/lib/agent.ex:246-250` (implementation) + +**What it does:** Agent is implemented entirely in terms of `GenServer.start_link(Agent.Server, fun, options)`. It's a thin abstraction that provides `get/update/get_and_update/cast` over GenServer's `call/cast`. + +**Why:** When your process is purely about state (no complex message handling, no multi-step workflows), Agent removes boilerplate. It's an intentional design choice by the Elixir team: provide the simplest possible stateful process, built on the same foundation. + +**Anti-pattern:** Using Agent for anything that needs custom message handling, multi-step coordination, or where you'd benefit from `handle_info` (timers, monitors). If you need more than get/update, use GenServer directly. + +**Code example from source:** +```elixir +# Agent is literally just GenServer with a purpose-built server module +@spec start_link((-> term), GenServer.options()) :: on_start +def start_link(fun, options \\ []) when is_function(fun, 0) do + GenServer.start_link(Agent.Server, fun, options) +end + +# Client/server boundary in Agent: expensive work placement matters +# Compute in the agent/server (blocks the agent for all other callers): +def get_something(agent) do + Agent.get(agent, fn state -> do_something_expensive(state) end) +end + +# Compute in the agent/client (copies state but doesn't block): +def get_something(agent) do + Agent.get(agent, & &1) |> do_something_expensive() +end +``` + +### When to Use + +**Triggers:** You need shared mutable state. The access pattern is simple get/update. No timers, monitors, or custom message handling needed. You want the minimum viable stateful process. + +**Example — before:** +```elixir +# Full GenServer for something that's just a counter +defmodule MyApp.Counter do + use GenServer + + def start_link(initial), do: GenServer.start_link(__MODULE__, initial, name: __MODULE__) + def value, do: GenServer.call(__MODULE__, :get) + def increment, do: GenServer.call(__MODULE__, :increment) + + @impl true + def init(n), do: {:ok, n} + @impl true + def handle_call(:get, _from, n), do: {:reply, n, n} + @impl true + def handle_call(:increment, _from, n), do: {:reply, n + 1, n + 1} +end +``` + +**Example — after:** +```elixir +# Agent: same semantics, less code +defmodule MyApp.Counter do + use Agent + + def start_link(initial), do: Agent.start_link(fn -> initial end, name: __MODULE__) + def value, do: Agent.get(__MODULE__, & &1) + def increment, do: Agent.get_and_update(__MODULE__, fn n -> {n + 1, n + 1} end) +end +``` + +### When NOT to Use + +**Don't use this when:** You need `handle_info` (timers, monitors, PubSub subscriptions). You need multi-step atomic operations beyond get_and_update. You need `handle_continue` for init. The state is large and you don't want full-state copies on every access. + +**Over-application example:** +```elixir +# Agent trying to do things Agents can't +defmodule MyApp.ConnectionPool do + use Agent + + def start_link(_) do + Agent.start_link(fn -> + # Can't use handle_continue — this blocks the supervisor + conns = Enum.map(1..10, fn _ -> Database.connect!() end) + %{connections: conns, checked_out: []} + end, name: __MODULE__) + end + + # Can't monitor checked-out connections for :DOWN + # Can't do periodic health checks (no handle_info) + # Can't implement checkout timeout logic +end +``` + +**Better alternative:** Use a GenServer (or a purpose-built pool library like `NimblePool` or `Poolboy`). Agent is for trivial state. The moment you need lifecycle management, monitoring, or timers, you've outgrown Agent. + +**Why:** Agent is intentionally limited. Fighting against those limits means you need GenServer. The Elixir team designed Agent for the "90% case of simple state" — not as a general-purpose process abstraction. + +--- + +## Pattern 11: Name Registration via `:via` Tuple + +**Source:** `lib/elixir/lib/gen_server.ex:1087-1107` (do_start implementation), `lib/elixir/lib/gen_server.ex:230-250` (documentation) + +**What it does:** GenServer supports four naming schemes: `nil` (anonymous), atom (local), `{:global, term}` (cluster-wide), and `{:via, module, term}` (pluggable registry). The implementation delegates to `:gen.start` with the appropriate format. + +**Why:** The `:via` pattern enables dynamic process naming without atom leaks. Since atoms are never garbage-collected, dynamic names (like per-user or per-session processes) must use `{:via, Registry, {registry, key}}` to avoid exhausting the atom table. + +**Anti-pattern:** Using `String.to_atom("user_#{id}")` for dynamic process names. This creates atoms that are never GC'd and will eventually crash the VM. + +**Code example from source:** +```elixir +# From gen_server.ex do_start/4 +defp do_start(link, module, init_arg, options) do + case Keyword.pop(options, :name) do + {nil, opts} -> + :gen.start(:gen_server, link, module, init_arg, opts) + + {atom, opts} when is_atom(atom) -> + :gen.start(:gen_server, link, {:local, atom}, module, init_arg, opts) + + {{:global, _term} = tuple, opts} -> + :gen.start(:gen_server, link, tuple, module, init_arg, opts) + + {{:via, via_module, _term} = tuple, opts} when is_atom(via_module) -> + :gen.start(:gen_server, link, tuple, module, init_arg, opts) + end +end +``` + +### When to Use + +**Triggers:** You're spawning processes dynamically (per-user, per-session, per-entity). The number of possible process names is unbounded or user-controlled. You need to look up processes by a dynamic key (user ID, room name, device serial). + +**Example — before:** +```elixir +# DANGEROUS: atom leak — each new user creates a permanent atom +defmodule UserSession do + def start_link(user_id) do + name = String.to_atom("user_session_#{user_id}") + GenServer.start_link(__MODULE__, user_id, name: name) + end + + def get_session(user_id) do + GenServer.call(String.to_atom("user_session_#{user_id}"), :get) + end +end +``` + +**Example — after:** +```elixir +# Safe: uses Registry — dynamic keys, no atom leak +defmodule UserSession do + def start_link(user_id) do + GenServer.start_link(__MODULE__, user_id, name: via(user_id)) + end + + def get_session(user_id) do + GenServer.call(via(user_id), :get) + end + + defp via(user_id), do: {:via, Registry, {MyApp.SessionRegistry, user_id}} +end +``` + +### When NOT to Use + +**Don't use this when:** You have a fixed, small number of singleton processes (use plain atom names). The process doesn't need to be looked up by name (just pass the pid). You're in a single-node system with < 100 named processes. + +**Over-application example:** +```elixir +# Overkill: Registry for a singleton +defmodule MyApp.Config do + def start_link(opts) do + GenServer.start_link(__MODULE__, opts, name: {:via, Registry, {MyApp.Registry, :config}}) + end +end +``` + +**Better alternative:** `name: MyApp.Config` — it's a singleton, atom names are fine. You'll never have more than one, so atom table pressure is zero. + +**Why:** `:via` adds a Registry dependency and lookup overhead. For singletons, a plain atom name is simpler, faster, and perfectly safe. Reserve `:via` for the use case it was designed for: dynamic, potentially unbounded process populations. + +--- + +## Pattern 12: GenServer as Anti-Pattern — Don't Use Processes for Code Organization + +**Source:** `lib/elixir/lib/gen_server.ex:381-415` ("When (not) to use a GenServer" section) + +**What it does:** The Elixir team explicitly documents that GenServer should NOT be used for code organization. A process must model a runtime property: mutable state, concurrency boundary, or failure isolation. + +**Why:** A GenServer serializes all access through a single process. Using it for stateless computation (like a calculator) creates artificial bottlenecks. Processes have overhead (memory, scheduling, message copying). Functions are free. + +**Anti-pattern (from the source itself):** +```elixir +# DON'T DO THIS — from gen_server.ex docs +def add(a, b) do + GenServer.call(__MODULE__, {:add, a, b}) +end + +def handle_call({:add, a, b}, _from, state) do + {:reply, a + b, state} +end + +# DO THIS instead: +def add(a, b) do + a + b +end +``` + +### When to Use + +**Triggers:** You catch yourself asking "should this be a GenServer?" Ask instead: "Does this need mutable state that outlives a single function call? Does this need to serialize access? Does this need independent failure isolation?" If yes to any, use GenServer. + +**Example — before:** +```elixir +# Stateless computation forced into a process +defmodule MyApp.Calculator do + use GenServer + + def start_link(_), do: GenServer.start_link(__MODULE__, nil, name: __MODULE__) + def add(a, b), do: GenServer.call(__MODULE__, {:add, a, b}) + def multiply(a, b), do: GenServer.call(__MODULE__, {:multiply, a, b}) + + @impl true + def init(_), do: {:ok, nil} + @impl true + def handle_call({:add, a, b}, _from, _), do: {:reply, a + b, nil} + @impl true + def handle_call({:multiply, a, b}, _from, _), do: {:reply, a * b, nil} +end +``` + +**Example — after:** +```elixir +# Pure functions — no process needed +defmodule MyApp.Calculator do + def add(a, b), do: a + b + def multiply(a, b), do: a * b +end +``` + +### When NOT to Use + +**Don't use this when:** N/A — this IS the "when not to use GenServer" pattern. The entire point is to remind you that not everything needs to be a process. + +**Over-application example:** +```elixir +# Taking this advice too far: avoiding GenServer when you actually need one +defmodule MyApp.RateLimiter do + # "Just use functions!" — but where does the counter live? + def allow?(user_id) do + # Can't track request counts without state... + # ETS? Still needs a process to own the table. + # This DOES need a GenServer (or ETS with a owner process). + true + end +end +``` + +**Better alternative:** If you need mutable state (counters, connections, caches), you need a process. The rule is "don't use processes for code organization" — not "never use processes." A rate limiter with shared counters is a legitimate use of GenServer (or ETS owned by a GenServer). + +**Why:** The pattern cuts both ways. Over-using GenServer creates bottlenecks. Under-using it means reinventing state management poorly. The litmus test: does the state need to survive between function calls? Does access need serialization? If yes, you need a process. diff --git a/patterns/testing.md b/patterns/testing.md index 50d9cca..1dbadb4 100644 --- a/patterns/testing.md +++ b/patterns/testing.md @@ -28,6 +28,75 @@ 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. +### When to Use + +**Triggers:** +- You're creating any new test module (always explicitly declare async intent) +- Tests are pure — no global name registration, no shared ETS tables, no filesystem writes to the same path +- You want faster test suite execution + +**Example — before:** +```elixir +defmodule UserTest do + use ExUnit.Case + # Defaults to async: false — runs serially for no reason + test "parses name" do + assert parse_name("Alice Bob") == {"Alice", "Bob"} + end +end +``` + +**Example — after:** +```elixir +defmodule UserTest do + use ExUnit.Case, async: true + # Pure tests run concurrently — suite finishes faster + test "parses name" do + assert parse_name("Alice Bob") == {"Alice", "Bob"} + end +end +``` + +### When NOT to Use + +**Don't use this when:** +- Tests register global process names (`:global`, `Process.register/2`) +- Tests modify shared application config (`Application.put_env`) +- Tests write to the same filesystem path without `@tag :tmp_dir` +- Tests depend on Logger configuration or capture stderr in exact mode + +**Over-application example:** +```elixir +defmodule ConfigTest do + use ExUnit.Case, async: true # DANGEROUS + + test "updates global config" do + Application.put_env(:my_app, :key, :value) + assert Application.get_env(:my_app, :key) == :value + end + # Another async test might see or clobber this config! +end +``` + +**Better alternative:** +```elixir +defmodule ConfigTest do + use ExUnit.Case # async: false — global state mutation + + setup do + original = Application.get_env(:my_app, :key) + on_exit(fn -> Application.put_env(:my_app, :key, original) end) + end + + test "updates global config" do + Application.put_env(:my_app, :key, :value) + assert Application.get_env(:my_app, :key) == :value + end +end +``` + +**Why:** Async tests run in parallel. Global state mutations in parallel tests produce race conditions — intermittent failures that are nightmares to debug. + --- ## 2. Parameterized Tests @@ -65,6 +134,82 @@ 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." +### When to Use + +**Triggers:** +- The same logic must work across multiple configurations (backends, modes, partition counts) +- You'd otherwise copy-paste an entire test module with minor variations +- The behavior under test is identical regardless of parameter — only setup differs + +**Example — before:** +```elixir +# Two nearly identical modules +defmodule CacheEtsTest do + use ExUnit.Case, async: true + setup do: %{cache: start_cache(:ets)} + test "get/set", %{cache: c}, do: # ... +end + +defmodule CacheRedisTest do + use ExUnit.Case, async: true + setup do: %{cache: start_cache(:redis)} + test "get/set", %{cache: c}, do: # ... # Same test! +end +``` + +**Example — after:** +```elixir +defmodule CacheTest do + use ExUnit.Case, + async: true, + parameterize: [%{backend: :ets}, %{backend: :redis}] + + setup %{backend: backend} do + %{cache: start_cache(backend)} + end + + test "get/set", %{cache: c} do + # Runs once per backend — no duplication + end +end +``` + +### When NOT to Use + +**Don't use this when:** +- Different parameters need different assertions (sign you're testing different behavior) +- You find yourself adding `if config.backend == :redis` inside tests +- There are only 2 simple variations (just write two tests with descriptive names) + +**Over-application example:** +```elixir +defmodule ApiTest do + use ExUnit.Case, + parameterize: [%{method: :get}, %{method: :post}] + + test "request", %{method: method} do + if method == :get do + assert get("/users") == 200 + else + assert post("/users", %{name: "x"}) == 201 # Different assertion! + end + end +end +``` + +**Better alternative:** +```elixir +test "GET /users returns 200" do + assert get("/users") == 200 +end + +test "POST /users creates a user" do + assert post("/users", %{name: "x"}) == 201 +end +``` + +**Why:** Parameterized tests assert the *same* behavior across configurations. If the assertions diverge per parameter, you're testing different things — write separate tests with clear names. + --- ## 3. Setup with `start_supervised/2` @@ -93,6 +238,57 @@ setup do end ``` +### When to Use + +**Triggers:** +- Your test needs a running process (GenServer, Registry, Supervisor, etc.) +- You want guaranteed cleanup even if the test crashes +- The process should have the same lifecycle as the test + +**Example — before:** +```elixir +setup do + {:ok, pid} = MyServer.start_link(name: :test_server) + on_exit(fn -> + if Process.alive?(pid), do: GenServer.stop(pid) + end) + %{server: pid} +end +``` + +**Example — after:** +```elixir +setup do + pid = start_supervised!(MyServer) + %{server: pid} +end +``` + +### When NOT to Use + +**Don't use this when:** +- You're testing the startup/shutdown behavior itself (need raw `start_link`) +- The process must outlive the test (rare — usually a test design smell) +- You need to test what happens when start_link fails + +**Over-application example:** +```elixir +# Testing that start_link fails with bad args +test "rejects invalid config" do + # start_supervised! will raise, hiding the actual error you want to test + assert {:error, _} = start_supervised({MyServer, invalid: true}) +end +``` + +**Better alternative:** +```elixir +test "rejects invalid config" do + assert {:error, {:bad_config, _}} = MyServer.start_link(invalid: true) +end +``` + +**Why:** `start_supervised` is for processes your test *needs running*. When you're testing failure modes of the process itself, call `start_link` directly so you can assert on the error tuple. + --- ## 4. Named Setup Functions (Composable Pipelines) @@ -112,6 +308,58 @@ defp start_server(context), do: {:ok, server: start_supervised!({MyServer, conte defp seed_data(context), do: :ok ``` +### When to Use + +**Triggers:** +- Setup has multiple independent steps that read better as named operations +- Different `describe` blocks need different combinations of setup steps +- You want to reuse individual setup steps across describe blocks + +**Example — before:** +```elixir +setup do + # 30 lines of mixed concerns in one block + {:ok, _} = start_supervised(Database) + user = insert(:user) + token = generate_token(user) + {:ok, socket} = connect(token) + %{user: user, socket: socket, token: token} +end +``` + +**Example — after:** +```elixir +setup [:start_database, :create_user, :connect_socket] + +defp start_database(_ctx), do: {:ok, db: start_supervised!(Database)} +defp create_user(_ctx), do: {:ok, user: insert(:user)} +defp connect_socket(%{user: user}), do: {:ok, socket: connect!(generate_token(user))} +``` + +### When NOT to Use + +**Don't use this when:** +- Setup is 1-3 lines (named function adds indirection without clarity) +- Steps are tightly coupled and can't be composed independently +- Only one describe block uses the setup (inline is simpler) + +**Over-application example:** +```elixir +# Over-decomposing trivial setup +setup [:assign_name] + +defp assign_name(_ctx), do: {:ok, name: "Alice"} +``` + +**Better alternative:** +```elixir +setup do + %{name: "Alice"} +end +``` + +**Why:** Named setup functions shine when they compose independently and describe preconditions. For trivial assignments, the indirection makes the code harder to follow, not easier. + --- ## 5. `on_exit` for Reversing Global Side Effects @@ -133,6 +381,58 @@ end **Key design:** `on_exit` runs in a *separate process* from the test, so it cannot interfere with test assertions. +### When to Use + +**Triggers:** +- Your test modifies global state (application config, Logger, ETS, registered names) +- State must be restored even if the test crashes or fails +- You need cleanup that runs after the test process dies + +**Example — before:** +```elixir +test "custom log level" do + Logger.configure(level: :error) + # test logic... + Logger.configure(level: :debug) # Never runs if test fails! +end +``` + +**Example — after:** +```elixir +test "custom log level" do + original = Logger.level() + on_exit(fn -> Logger.configure(level: original) end) + Logger.configure(level: :error) + # test logic — cleanup guaranteed +end +``` + +### When NOT to Use + +**Don't use this when:** +- `start_supervised` handles the lifecycle (it cleans up automatically) +- The state is test-local (process dictionary, local variables) +- You're using it for process cleanup that `start_supervised` does better + +**Over-application example:** +```elixir +setup do + {:ok, pid} = MyWorker.start_link() + on_exit(fn -> Process.exit(pid, :kill) end) + %{worker: pid} +end +``` + +**Better alternative:** +```elixir +setup do + pid = start_supervised!(MyWorker) + %{worker: pid} +end +``` + +**Why:** `on_exit` is for *global* side effects that `start_supervised` can't handle. For process lifecycle, `start_supervised` is more robust — it handles ordering, linking, and shutdown properly. + --- ## 6. Pattern Match Assertions @@ -157,6 +457,51 @@ assert {:count, ^x} = get_counter() assert match?([%{id: id} | _] when is_integer(id), records) ``` +### When to Use + +**Triggers:** +- You care about the shape/structure but not every field +- You want to bind a value from the result for further assertions +- The response has dynamic fields (IDs, timestamps) you can't predict + +**Example — before:** +```elixir +result = create_user("alice") +assert elem(result, 0) == :ok +user = elem(result, 1) +assert Map.has_key?(user, :id) +assert is_integer(user.id) +``` + +**Example — after:** +```elixir +assert {:ok, %{id: id}} = create_user("alice") +assert is_integer(id) +``` + +### When NOT to Use + +**Don't use this when:** +- You need to assert exact equality (use `==`) +- The pattern is so loose it would match unintended values +- You're asserting on a boolean or simple scalar + +**Over-application example:** +```elixir +# Pattern match that matches too broadly +assert {:ok, _} = dangerous_operation() +# This passes for {:ok, nil}, {:ok, :error_actually}, anything! +``` + +**Better alternative:** +```elixir +assert {:ok, %User{active: true}} = dangerous_operation() +# Or when you need exact value: +assert create_user("alice") == {:ok, %User{name: "alice", active: true}} +``` + +**Why:** Pattern match assertions are for structural validation. If your pattern is so loose it can't distinguish success from unexpected values, you're not actually testing anything meaningful. + --- ## 7. `assert_receive` / `refute_receive` for Process Communication @@ -190,6 +535,57 @@ test "exit(pid, :normal) does not cause the target process to exit" do end ``` +### When to Use + +**Triggers:** +- Testing async process communication (messages, monitors, links) +- You need to verify a message was sent without blocking indefinitely +- Testing pub/sub, GenServer casts, or event broadcasts + +**Example — before:** +```elixir +test "notifies subscriber" do + subscribe(self(), :topic) + publish(:topic, "hello") + Process.sleep(100) # Hope it arrived... + assert_received {:topic, "hello"} # assert_received checks mailbox NOW +end +``` + +**Example — after:** +```elixir +test "notifies subscriber" do + subscribe(self(), :topic) + publish(:topic, "hello") + assert_receive {:topic, "hello"}, 500 # Waits UP TO 500ms +end +``` + +### When NOT to Use + +**Don't use this when:** +- The operation is synchronous (use regular `assert` with the return value) +- You're testing a GenServer.call (it already returns the result synchronously) +- The default 100ms timeout would make tests flaky — consider a longer timeout or redesigning + +**Over-application example:** +```elixir +# Using assert_receive for a synchronous operation +test "get user" do + send(self(), {:user, get_user(1)}) # Why send to self? + assert_receive {:user, %User{id: 1}} +end +``` + +**Better alternative:** +```elixir +test "get user" do + assert %User{id: 1} = get_user(1) +end +``` + +**Why:** `assert_receive` is for genuinely async communication — messages that arrive independently of the test's control flow. For synchronous return values, just assert on the return directly. + --- ## 8. Testing GenServers via Public API (No Internal State Inspection) @@ -212,6 +608,64 @@ test "start_link/2, call/2 and cast/2" do end ``` +### When to Use + +**Triggers:** +- Testing any GenServer, Agent, or stateful process +- You want tests that survive internal refactoring +- The process has a well-defined public API + +**Example — before:** +```elixir +test "push adds to stack" do + {:ok, pid} = Stack.start_link([:hello]) + GenServer.cast(pid, {:push, :world}) + # Peeking at internal state — couples test to implementation + assert :sys.get_state(pid) == [:world, :hello] +end +``` + +**Example — after:** +```elixir +test "push adds to stack" do + {:ok, pid} = Stack.start_link([:hello]) + GenServer.cast(pid, {:push, :world}) + assert GenServer.call(pid, :pop) == :world + assert GenServer.call(pid, :pop) == :hello +end +``` + +### When NOT to Use + +**Don't use this when:** +- You're debugging a specific state corruption bug (temporarily peek, then fix) +- The process has no public query API and adding one just for tests would bloat the interface +- You're testing internal state transitions explicitly (state machine verification) + +**Over-application example:** +```elixir +# Avoiding state inspection when there's no query API +test "handles concurrent updates" do + cast(pid, {:increment, 5}) + cast(pid, {:increment, 3}) + # No way to observe the result without :sys.get_state or adding a query call + Process.sleep(100) + # ...can't assert anything useful +end +``` + +**Better alternative:** +```elixir +# Add a minimal query function to the public API +test "handles concurrent updates" do + cast(pid, {:increment, 5}) + cast(pid, {:increment, 3}) + assert call(pid, :get_count) == 8 +end +``` + +**Why:** The principle is "test through the API," not "never observe state." If your process lacks observability, add a read function — that's a better public API, not just a test convenience. + --- ## 9. `catch_exit` for Testing Process Failures @@ -238,6 +692,57 @@ test "exits on task error" do end ``` +### When to Use + +**Triggers:** +- Testing that a GenServer call times out or exits with a specific reason +- Testing that linked processes propagate exit signals correctly +- Verifying OTP shutdown behavior and exit reasons + +**Example — before:** +```elixir +test "times out" do + # Just assert it raises... but exit != raise + assert_raise RuntimeError, fn -> + GenServer.call(pid, :slow, 1) + end + # This fails! Exits aren't raises. +end +``` + +**Example — after:** +```elixir +test "times out" do + assert catch_exit(GenServer.call(pid, :slow, 1)) == + {:timeout, {GenServer, :call, [pid, :slow, 1]}} +end +``` + +### When NOT to Use + +**Don't use this when:** +- The function raises an exception (use `assert_raise` instead) +- You can test the error through the public API's return value `{:error, reason}` +- The exit is a side effect you don't care about (just test the observable behavior) + +**Over-application example:** +```elixir +# Using catch_exit when the API already returns error tuples +test "handles missing key" do + assert catch_exit(GenServer.call(pid, {:get, :missing})) + # The server actually returns {:error, :not_found} — no exit! +end +``` + +**Better alternative:** +```elixir +test "handles missing key" do + assert GenServer.call(pid, {:get, :missing}) == {:error, :not_found} +end +``` + +**Why:** `catch_exit` is specifically for OTP exit signals (`:timeout`, `:noproc`, `:normal`, `{:shutdown, reason}`). If the function returns error tuples rather than exiting, assert on the return value directly. + --- ## 10. `@tag capture_log: true` for Suppressing Expected Log Output @@ -260,6 +765,70 @@ end @moduletag :capture_log ``` +### When to Use + +**Triggers:** +- Tests intentionally trigger error paths that log warnings/errors +- Test output is noisy with expected error messages +- You want clean `mix test` output but need logs preserved for debugging failures + +**Example — before:** +```elixir +test "handles crash" do + # Passes, but test output shows: + # [error] GenServer #PID<0.123.0> terminating + # [error] ** (RuntimeError) intentional crash + crash_worker(pid) + assert_receive {:restarted, _} +end +``` + +**Example — after:** +```elixir +@tag capture_log: true +test "handles crash" do + crash_worker(pid) + assert_receive {:restarted, _} + # Logs captured — only shown if this test FAILS +end +``` + +### When NOT to Use + +**Don't use this when:** +- You want to assert on log content (use `capture_log/2` function instead) +- The logs indicate a real problem you should fix, not expected behavior +- You're hiding logs to mask flaky tests + +**Over-application example:** +```elixir +@moduletag :capture_log # Blanket capture on entire module + +test "creates user" do + # This test logs "[warn] duplicate email check" — is that expected? + # By capturing everything, you might miss real warnings + assert {:ok, _} = create_user(attrs) +end +``` + +**Better alternative:** +```elixir +# Only capture on tests that INTENTIONALLY trigger errors +test "creates user" do + assert {:ok, _} = create_user(attrs) + # If there's a warning, investigate it — don't hide it +end + +@tag capture_log: true +test "rejects duplicate email" do + create_user(attrs) + assert {:error, :duplicate} = create_user(attrs) + # Expected warning about duplicate — captured +end +``` + +**Why:** Blanket log capture hides signals. Apply `capture_log` surgically to tests where you *expect* error output. Unexpected logs in other tests might reveal bugs. + --- ## 11. `capture_log` / `capture_io` for Content Assertions @@ -292,6 +861,59 @@ assert output == "a\n" **Important for async tests:** Use `=~` instead of `==` for `:stderr` captures because output from other tests may interleave. +### When to Use + +**Triggers:** +- You need to verify specific log messages are emitted +- Testing CLI output or formatted display +- Verifying error messages are user-friendly + +**Example — before:** +```elixir +test "logs warning on retry" do + # Just call it and... hope the log is right? + retry_request(url) + # No assertion on the log content +end +``` + +**Example — after:** +```elixir +test "logs warning on retry" do + log = capture_log(fn -> retry_request(url) end) + assert log =~ "retrying request" + assert log =~ url +end +``` + +### When NOT to Use + +**Don't use this when:** +- You just want to suppress noisy logs (use `@tag capture_log: true`) +- The log message is an implementation detail that shouldn't be part of the contract +- Testing the behavior is sufficient — the log is incidental + +**Over-application example:** +```elixir +test "creates user" do + log = capture_log(fn -> + assert {:ok, user} = create_user(attrs) + end) + assert log =~ "INSERT INTO users" # Testing SQL logs?! Fragile. +end +``` + +**Better alternative:** +```elixir +test "creates user" do + assert {:ok, user} = create_user(attrs) + assert user.name == "Alice" + # SQL logs are an implementation detail — don't assert on them +end +``` + +**Why:** Assert on logs that are *part of the contract* (user-facing warnings, audit trail). Don't assert on incidental logs that change with implementation — they make tests brittle. + --- ## 12. `describe` Blocks for Logical Grouping @@ -319,6 +941,61 @@ end **Constraint:** Describe blocks cannot be nested. `setup_all` cannot appear inside describe. +### When to Use + +**Triggers:** +- A module tests multiple public functions +- Tests share setup that's specific to one function/feature +- You want test output organized by feature (e.g., "describe await/2 — exits on timeout") + +**Example — before:** +```elixir +test "push adds element" do ... end +test "push returns :ok" do ... end +test "pop returns top element" do ... end +test "pop from empty raises" do ... end +# Flat list — hard to see which tests cover which function +``` + +**Example — after:** +```elixir +describe "push/2" do + test "adds element to the top" do ... end + test "returns :ok" do ... end +end + +describe "pop/1" do + test "returns top element" do ... end + test "raises on empty stack" do ... end +end +``` + +### When NOT to Use + +**Don't use this when:** +- The module tests one function (describe adds an unnecessary nesting level) +- You'd have a describe block with a single test in it +- You need nested grouping (describe can't nest — use separate modules) + +**Over-application example:** +```elixir +describe "parse/1" do + test "parses input" do + assert parse("hello") == {:ok, "hello"} + end +end +# One test in a describe — just use a standalone test +``` + +**Better alternative:** +```elixir +test "parse/1 parses input" do + assert parse("hello") == {:ok, "hello"} +end +``` + +**Why:** `describe` provides structure when there are multiple tests per feature. A single test in a describe is over-organization — the describe name adds visual noise without grouping benefit. + --- ## 13. `ExUnit.CaseTemplate` for Shared Test Infrastructure @@ -366,6 +1043,83 @@ defmodule LoggerTest do end ``` +### When to Use + +**Triggers:** +- Multiple test modules share the same setup/teardown logic +- You're building a test DSL (e.g., `DataCase` for database tests in Phoenix) +- Shared helpers need to be imported into every test module of a certain type + +**Example — before:** +```elixir +# Repeated in every test module that uses the database +defmodule UsersTest do + use ExUnit.Case + setup do + :ok = Ecto.Adapters.SQL.Sandbox.checkout(Repo) + Ecto.Adapters.SQL.Sandbox.mode(Repo, {:shared, self()}) + end +end + +defmodule PostsTest do + use ExUnit.Case + setup do + :ok = Ecto.Adapters.SQL.Sandbox.checkout(Repo) # Same thing again + Ecto.Adapters.SQL.Sandbox.mode(Repo, {:shared, self()}) + end +end +``` + +**Example — after:** +```elixir +defmodule MyApp.DataCase do + use ExUnit.CaseTemplate + + setup do + :ok = Ecto.Adapters.SQL.Sandbox.checkout(Repo) + Ecto.Adapters.SQL.Sandbox.mode(Repo, {:shared, self()}) + end +end + +defmodule UsersTest do + use MyApp.DataCase # All DB setup handled +end +``` + +### When NOT to Use + +**Don't use this when:** +- Only one or two test modules share the setup (just use named setup functions) +- The template would have many options making it hard to understand what's actually set up +- You're hiding important test context behind abstraction + +**Over-application example:** +```elixir +defmodule MyApp.SuperCase do + use ExUnit.CaseTemplate + using do + quote do + import MyApp.Factory + import MyApp.Assertions + import MyApp.Helpers + alias MyApp.{Repo, User, Post, Comment, Tag} + # 20 more aliases... + end + end + # Every test gets everything whether it needs it or not +end +``` + +**Better alternative:** +```elixir +# Focused templates for specific test types +defmodule MyApp.DataCase do ... end # Database tests +defmodule MyApp.ConnCase do ... end # HTTP tests +defmodule MyApp.ChannelCase do ... end # WebSocket tests +``` + +**Why:** A "god template" that imports everything creates implicit dependencies and makes it impossible to know what a test actually needs. Use focused templates that match distinct test categories. + --- ## 14. `doctest` Integration @@ -387,6 +1141,70 @@ end doctest Kernel, except: [===: 2, !==: 2, and: 2, or: 2] ``` +### When to Use + +**Triggers:** +- Your module has `iex>` examples in `@doc` or `@moduledoc` +- You want to ensure documentation examples stay correct across refactors +- The function has simple, deterministic input/output that's easy to show in docs + +**Example — before:** +```elixir +@doc """ +Doubles a number. + + iex> double(5) + 10 +""" +def double(n), do: n * 2 +# No doctest — this example might rot if you rename the function +``` + +**Example — after:** +```elixir +# In test file: +defmodule MathTest do + use ExUnit.Case, async: true + doctest MyApp.Math + # Now the iex> example is verified on every test run +end +``` + +### When NOT to Use + +**Don't use this when:** +- Examples require complex setup (database, processes, external state) +- Output is non-deterministic (timestamps, random values, PIDs) +- The function's behavior is better tested with dedicated unit tests + +**Over-application example:** +```elixir +@doc """ +Creates a user. + + iex> {:ok, user} = create_user(%{name: "Alice"}) + iex> user.id + 1 +""" +# User ID is auto-incremented — this breaks on second run! +``` + +**Better alternative:** +```elixir +@doc """ +Creates a user. + + {:ok, user} = MyApp.create_user(%{name: "Alice"}) + user.name + #=> "Alice" + +""" +# Don't use iex> prefix — this is illustrative, not a doctest +# Test the actual behavior in a proper test with DB setup +``` + +**Why:** Doctests are for deterministic, setup-free examples. If the example needs a database, process, or produces non-deterministic output, write a proper test — doctests can't handle setup/teardown. + --- ## 15. `Process.sleep(:infinity)` as a Process Parking Pattern @@ -413,6 +1231,68 @@ 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. +### When to Use + +**Triggers:** +- You need a process that exists as a test subject (for monitoring, registry, supervision tests) +- The process doesn't need to do anything — just exist and be killable +- Testing cleanup/shutdown behavior when a process dies + +**Example — before:** +```elixir +test "monitors processes" do + pid = spawn(fn -> + receive do: (:stop -> :ok) # Must send :stop to clean up + end) + ref = Process.monitor(pid) + send(pid, :stop) + assert_receive {:DOWN, ^ref, _, _, :normal} +end +``` + +**Example — after:** +```elixir +test "monitors processes" do + pid = spawn(fn -> Process.sleep(:infinity) end) + ref = Process.monitor(pid) + Process.exit(pid, :kill) + assert_receive {:DOWN, ^ref, _, _, :killed} +end +``` + +### When NOT to Use + +**Don't use this when:** +- You need the process to actually do something (send messages, handle calls) +- You're using it as a timing mechanism (`Process.sleep(100)` for "wait a bit") +- The process should be managed by `start_supervised` (which handles cleanup) + +**Over-application example:** +```elixir +test "worker processes requests" do + pid = spawn(fn -> + Process.sleep(:infinity) # But the test needs it to handle messages! + end) + send(pid, {:process, data}) + assert_receive {:result, _} # Never arrives — process is sleeping! +end +``` + +**Better alternative:** +```elixir +test "worker processes requests" do + pid = spawn(fn -> + receive do + {:process, data} -> send(parent, {:result, transform(data)}) + end + end) + send(pid, {:process, data}) + assert_receive {:result, _} +end +``` + +**Why:** `Process.sleep(:infinity)` is for *inert* test subjects — processes that exist to be observed, not to perform work. If the test needs the process to respond, it needs a receive loop, not a sleep. + --- ## 16. Helper Functions for Test-Specific Behavior @@ -452,6 +1332,79 @@ defmodule TaskTest do end ``` +### When to Use + +**Triggers:** +- The same 3-5 line pattern repeats across multiple tests in the module +- The helper name makes tests more readable by expressing intent +- Setup logic is complex enough to obscure the test's actual assertion + +**Example — before:** +```elixir +test "cleans up on crash 1" do + ref = Process.monitor(pid1) + Process.exit(pid1, :kill) + assert_receive {:DOWN, ^ref, _, _, _} + assert Registry.lookup(reg, :key) == [] +end + +test "cleans up on crash 2" do + ref = Process.monitor(pid2) + Process.exit(pid2, :kill) + assert_receive {:DOWN, ^ref, _, _, _} # Same 3 lines again + assert Registry.lookup(reg, :key) == [] +end +``` + +**Example — after:** +```elixir +defp kill_and_wait(pid) do + ref = Process.monitor(pid) + Process.exit(pid, :kill) + assert_receive {:DOWN, ^ref, _, _, _} +end + +test "cleans up on crash 1" do + kill_and_wait(pid1) + assert Registry.lookup(reg, :key) == [] +end +``` + +### When NOT to Use + +**Don't use this when:** +- The "helper" is used only once (inline it) +- The helper hides important test details that readers need to see +- Over-abstraction makes tests harder to understand in isolation + +**Over-application example:** +```elixir +defp setup_and_assert(input, expected) do + {:ok, pid} = start_supervised({Worker, input}) + result = Worker.process(pid) + assert result == expected +end + +test "processes integers", do: setup_and_assert(42, {:ok, 42}) +test "processes strings", do: setup_and_assert("hi", {:ok, "hi"}) +# Tests are now opaque — can't see what's actually being tested +``` + +**Better alternative:** +```elixir +test "processes integers" do + pid = start_supervised!({Worker, 42}) + assert Worker.process(pid) == {:ok, 42} +end + +test "processes strings" do + pid = start_supervised!({Worker, "hi"}) + assert Worker.process(pid) == {:ok, "hi"} +end +``` + +**Why:** Test helpers should extract *mechanics* (kill-and-wait, setup-server), not *logic* (the actual behavior under test). If a helper contains assertions, readers can't understand the test without reading the helper. + --- ## 17. `@tag :tmp_dir` for Filesystem Tests @@ -472,6 +1425,59 @@ test "writes files", %{tmp_dir: tmp_dir} do end ``` +### When to Use + +**Triggers:** +- Tests create, read, or modify files +- Multiple tests would conflict if they used the same paths +- You want filesystem tests to run with `async: true` + +**Example — before:** +```elixir +test "writes config file" do + path = "/tmp/test_config.json" + File.write!(path, "{}") + # Another async test writes to the same path — race condition! + assert File.read!(path) == "{}" +end +``` + +**Example — after:** +```elixir +@tag :tmp_dir +test "writes config file", %{tmp_dir: dir} do + path = Path.join(dir, "config.json") + File.write!(path, "{}") + assert File.read!(path) == "{}" + # Unique directory per test — safe for async +end +``` + +### When NOT to Use + +**Don't use this when:** +- Tests only read files (no isolation needed for read-only access) +- You're testing in-memory operations that happen to involve path strings +- The filesystem interaction is mocked/stubbed + +**Over-application example:** +```elixir +@tag :tmp_dir +test "parses path components", %{tmp_dir: _dir} do + # Doesn't actually touch the filesystem! + assert Path.basename("/foo/bar/baz.txt") == "baz.txt" +end +``` + +**Better alternative:** +```elixir +test "parses path components" do + assert Path.basename("/foo/bar/baz.txt") == "baz.txt" +end +``` + +**Why:** `@tag :tmp_dir` creates actual directories on disk — it's overhead. Only use it when the test genuinely needs filesystem isolation. Pure path manipulation doesn't touch the filesystem. + --- ## 18. `assert_raise` with Message Matching @@ -495,6 +1501,65 @@ assert_raise RuntimeError, ~r/^today's lucky number is 0\.\d+!$/, fn -> end ``` +### When to Use + +**Triggers:** +- Testing error messages that users will see +- Validating that errors provide actionable information +- The error type alone isn't specific enough (many places raise `ArgumentError`) + +**Example — before:** +```elixir +test "rejects bad input" do + assert_raise ArgumentError, fn -> + parse("not_a_number") + end + # Passes even if the message is wrong or unhelpful +end +``` + +**Example — after:** +```elixir +test "rejects bad input" do + assert_raise ArgumentError, ~r/cannot parse .* as integer/, fn -> + parse("not_a_number") + end + # Verifies the error message is helpful +end +``` + +### When NOT to Use + +**Don't use this when:** +- The exact message text is an implementation detail that changes often +- You're testing a third-party library's error messages (they might change) +- The exception type alone is sufficient to distinguish the error case + +**Over-application example:** +```elixir +test "file not found" do + assert_raise File.Error, "could not read file \"/no/such/file\": no such file or directory", fn -> + File.read!("/no/such/file") + end + # Exact message match breaks across OS versions / locales +end +``` + +**Better alternative:** +```elixir +test "file not found" do + assert_raise File.Error, fn -> + File.read!("/no/such/file") + end + # Or with regex for the stable part: + assert_raise File.Error, ~r/could not read file/, fn -> + File.read!("/no/such/file") + end +end +``` + +**Why:** Match the *stable, meaningful* part of error messages. OS-specific paths, locale-dependent strings, and implementation details make exact matches brittle across environments. + --- ## 19. `@moduletag` / `@describetag` for Cross-Cutting Configuration @@ -522,6 +1587,66 @@ defmodule SystemTest do end ``` +### When to Use + +**Triggers:** +- Tests only run on certain platforms (OS, architecture) +- You want to run a subset of tests via `--include` / `--exclude` +- A whole module or describe block shares configuration (capture_log, tmp_dir) + +**Example — before:** +```elixir +# Manually skipping in each test +test "symlinks" do + if :os.type() != {:unix, :linux}, do: ExUnit.skip("unix only") + # ... +end + +test "permissions" do + if :os.type() != {:unix, :linux}, do: ExUnit.skip("unix only") + # ... +end +``` + +**Example — after:** +```elixir +describe "Unix filesystem" do + @describetag :unix + + test "symlinks" do ... end + test "permissions" do ... end +end + +# In test_helper.exs: +ExUnit.configure(exclude: [:unix], include: []) +# Run with: mix test --include unix +``` + +### When NOT to Use + +**Don't use this when:** +- The tag applies to a single test (use `@tag` instead) +- You're using tags as a substitute for proper test organization +- The tag doesn't enable filtering or configuration — it's just metadata no one reads + +**Over-application example:** +```elixir +@moduletag :unit +@moduletag :fast +@moduletag :users +@moduletag :important +# Tags no one filters on — just noise in the module header +``` + +**Better alternative:** +```elixir +# Only tag what you actually filter on +@moduletag :capture_log # Used by ExUnit +# If you never run `--include unit`, don't tag it +``` + +**Why:** Tags have cost — they clutter the module header and create the illusion of organization. Only add tags that drive behavior (ExUnit configuration) or that you actually filter on in CI/development. + --- ## 20. Context Pattern Matching in Test Signatures @@ -543,3 +1668,59 @@ end ``` The `%{test: name}` pattern is ubiquitous — the test name is unique per module, making it perfect for naming registered processes in async tests. + +### When to Use + +**Triggers:** +- Your test needs values from `setup` (server pids, registry names, tmp dirs) +- You want to make test dependencies visible in the test signature +- Using `%{test: name}` for unique process registration in async tests + +**Example — before:** +```elixir +setup do + {:ok, pid} = start_supervised(MyServer) + %{server: pid, name: "test_user"} +end + +test "queries server" do + # Where does 'server' come from? Must read setup. + server = ??? +end +``` + +**Example — after:** +```elixir +setup do + {:ok, pid} = start_supervised(MyServer) + %{server: pid, name: "test_user"} +end + +test "queries server", %{server: pid, name: name} do + assert MyServer.get(pid, name) == :ok +end +``` + +### When NOT to Use + +**Don't use this when:** +- The test doesn't use any setup context +- You're destructuring the entire context when you only need one field +- The test is standalone and self-contained + +**Over-application example:** +```elixir +test "basic math", %{test: _test} do + # Destructuring context for a test that doesn't need it + assert 2 + 2 == 4 +end +``` + +**Better alternative:** +```elixir +test "basic math" do + assert 2 + 2 == 4 +end +``` + +**Why:** Context destructuring signals "this test depends on external setup." If the test is self-contained, the pattern match is misleading — readers will look for setup that doesn't exist or isn't needed.