| name | elixir-implementing |
| description | Elixir for idiomatic implementation — the decision tables, templates, and anti-patterns that make Claude write functional, idiomatic, best-practices Elixir at the moment of writing. Covers the full daily-coding toolkit: pattern matching, pipelines, with/case/cond, Enum/Stream/for, multi-clause dispatch, ok/error flow, OTP callback patterns, context boundaries, configuration, test-driven development with ExUnit and Mox, and the specific anti-patterns Claude commonly produces in Elixir. ALWAYS use when writing Elixir code. ALWAYS use when deciding between control-flow constructs (if/case/with/multi-clause). ALWAYS use when writing GenServer/Task/Agent callbacks or implementing a behaviour. ALWAYS use when writing tests or practicing TDD in Elixir. ALWAYS use when refactoring Elixir toward idiomatic form. For upfront architecture/design work (contexts, supervision shape, project layout) also load elixir-planning; this skill defers deep architecture decisions to that skill.
|
Elixir — Implementing Skill
This skill is optimized for the moment of writing Elixir code. It is one of three Elixir skills:
- elixir-implementing (this) — what to type. Rules, decision tables, idiomatic templates, anti-patterns, daily-coding operations.
- elixir-planning — what to build before typing. Architectural decisions: contexts, process shape, supervision, project layout.
- elixir-reviewing — how to critique existing code. Anti-pattern catalog + review checklist.
The three skills follow the skill-authoring three-modes framework: rules constrain (fire during review), decision tables guide (fire at moment of writing), BAD/GOOD pairs verify (fire during validation). Elixir is implementation-heavy — decision tables carry the most weight in this skill.
Subskills — deep implementation references
This skill's SKILL.md carries the always-loaded decision tables, top anti-patterns, and core rules. For detail depth on a specific area, load the matching subskill:
| Subskill | Purpose | Load when writing... |
|---|
| idioms-reference.md | Pattern matching (incl. pin operator advanced, <> prefix matching, multi-clause default args, assertive matching), guards, case/cond/if, with chains, pipelines, for comprehensions, captures, IO lists, error handling, Enum (common + 30+ functions), Stream (custom streams via Stream.resource/transform, Enum-vs-Stream decision), advanced reduce (map_reduce, flat_map_reduce, scan, multi-accumulator), recursion (TCO, accumulator-reverse, tree traversal, mutual), Protocols (defprotocol, defimpl, @derive, Enumerable/Collectable/Inspect patterns, consolidation), Behaviours (@callback/@optional_callbacks, @impl, use + defoverridable, Mox), Imperative→Elixir translation tables | Daily Elixir code — idiomatic control flow, transforms, polymorphism |
| data-reference.md | Maps, structs, keywords, tuples, lists, MapSet, binaries, IO lists — complexity table + call patterns | Anything touching data-structure manipulation |
| otp-callbacks.md | GenServer/Task/Agent/:gen_statem callback templates, supervisor child specs, Registry via-tuples, ETS calls, GenStage/Broadway/Flow templates | GenServer/Task/supervisor/streaming code |
| ecto-patterns.md | Schemas, changesets, queries, migrations, Multi, custom types, schemaless changesets | Any Ecto code — schema or query |
| testing-patterns.md | ExUnit, Mox, sandbox setup, factories, LiveView/Channel/Oban test helpers, property tests | Any test file |
| type-and-docs.md | @spec, @type, @doc, @moduledoc, doctests, Dialyzer config, built-in types, binary/String.t/iodata decision, closed vs open map types, dynamic() gradual typing | Adding types and documentation |
| networking-patterns.md | :gen_tcp/:gen_udp templates, acceptor loops, protocol framing (length-prefix, line, TLV), Ranch/Thousand Island handlers, TLS, HTTP clients | TCP/UDP/HTTP code |
| code-style.md | .formatter.exs template, Credo check catalog, module organization, function ordering, sigil selection, defdelegate decision, readable-code patterns, style BAD/GOOD | Any Elixir code — ensures style-compliant output |
| production-patterns.md | Production Phoenix patterns (schema base, kit modules, response cache, policy, controller context injection, HTTP SSL fallback, Oban telemetry reporter), NimbleOptions, Mix custom tasks & quality aliases, library authoring conventions | Writing production-ready app code or publishing a library to Hex |
For architecture-level decisions (which constructs/processes/contexts to use BEFORE writing code), load elixir-planning. For critique of existing code, load elixir-reviewing.
How to navigate this skill while coding:
- Starting a feature? — Read §1 (Rules) and §3 (TDD workflow). Write a failing test before any implementation.
- At the keyboard, choosing between constructs? — Jump to §2 (Master "Which Construct?" table). Find your intent in the left column.
- Unsure how to structure a specific pattern? — §5 has idiomatic templates for the patterns Claude most often gets wrong.
- Validating what you wrote? — Cross-check against §7 (Anti-patterns BAD/GOOD).
- Testing is part of writing code, not after. §3 and §4 are core, not optional.
Final section layout:
| § | Section | Mode |
|---|
| 0 | The TDD Gate — Read Before Any Implementation | Gate ⛔ |
| 1 | Rules for Writing Elixir | Rules |
| 2 | Master "Which Construct?" Decision Guide | Decision ⭐ |
| 3 | TDD Workflow | Rules + Decision |
| 4 | Testing Essentials | Decision + Templates |
| 5 | Critical Patterns Claude Commonly Gets Wrong | Templates + BAD/GOOD |
| 6 | Idiomatic Elixir Constructs | Decision + Templates |
| 7 | Anti-patterns Claude Commonly Produces | BAD/GOOD ⭐ |
| 8 | Daily Operations — Error, Modules, Naming, Docs | Rules + Templates |
| 9 | OTP Key Decisions | Decision ⭐ |
| 10 | Architecture Key Decisions | Decision |
| 11 | Domain Handoffs to Specialized Skills | Routing |
| 12 | Quick References — Stdlib Cheat Sheets | Lookup |
| 13 | Related Skills | Navigation |
Scope — what this skill does NOT cover:
- Upfront architecture, supervision-tree shape, context boundaries, project layout → load
elixir-planning.
- Review of existing code, audits, profiling, debugging playbooks → load
elixir-reviewing.
- Deep LiveView/Phoenix, Ecto migrations beyond the essentials, Ash domain modeling → load the respective framework skill.
- General runtime debugging, ops dashboards, performance tuning under load → out of scope; reach for
:observer, BEAM flame graphs, and live production traces.
0. The TDD Gate — Read Before Any Implementation ⛔
Stop. This section fires at a higher abstraction level than §1 (Rules) or §2 (Decision tables). Those fire when you're already mid-implementation. This one fires at the decision to start implementation, and it overrides anything below it. If you're at the keyboard about to type def some_new_function, you are in scope.
0.1 The gate — four questions before any new production code
For every new public function you are about to write:
- What is the function's name and module?
- What is the test file and test name that covers its happy path?
- Does that test exist on disk right now?
- When you ran the test suite in the last few minutes, did that specific test fail for the expected reason (missing function, wrong return shape — not a compilation error in your test)?
If ANY answer is "no", "I'll write it after", or "I remember running it earlier", STOP. Go write the test, run mix test path/to/test.exs, confirm red. Then come back and implement.
No exceptions for the categories in §3.3 column A (public API, business logic, refactors, changeset validations, with chains, context-boundary crossings). Those are the categories most likely to ship a bug without a test. They are NOT the exceptions to the gate — they are why the gate exists.
0.2 Why this section exists (read once, remember always)
A prior autonomous session built ~5.3k LoC across 12 milestones with elixir-implementing loaded before every milestone. Rule 1 said "ALWAYS write the test first." §3 described the RED-GREEN-REFACTOR loop. §3.3 listed the categories that require tests-first. Tests-first was followed on zero milestones. Four real bugs shipped into green commits and were only caught when tests were finally written in a review-fix pass:
DETS :ordered_set — doesn't exist (only in ETS). Sat for three milestones.
Ecto.UUID.generate/0 through insert_all — 36-char binary encode error.
NaiveDateTime leaking from string-source Ecto queries.
- LV stream rows not re-rendering on external assign changes — drove architectural rework.
The rules existed. They did not fire at the right abstraction level. The gate above is the correction.
0.3 Milestone-boundary checklist
Before committing a milestone, feature, or any multi-module change:
- Name every new public function added this milestone.
- For each, name the test file and line covering the happy path.
- For each, name at least one test covering an error path.
- Is there a commit (or visible step in the diff) where the test file appeared before the implementation? If commits are squashed, the test file must be visible in the same diff as the implementation — but written first by the process.
- If any of the above answers is "no", DO NOT commit. Go write the tests, confirm they would have caught something if the implementation were wrong, then commit.
If this checklist was not visible to you before you started writing the code, you are doing tests-after regardless of how you remember the sequence. Assume tests-after as the null hypothesis; require evidence to disprove it.
0.4 Bug-fix retrospective — fires on every bug fix
After any commit that fixes a bug:
- Could this bug have been reproduced in a test before the fix? If no, why not?
- Was there a test for the surrounding module at the time the bug shipped?
- If not, the fix commit MUST include both (a) a test that would have caught the original bug and (b) tests for adjacent untested behavior in the same module.
Shipping a bug fix without a regression test is not a fix — it is a statement that the bug class is acceptable. It will recur.
0.5 Autonomous-mode warning
In long autonomous or milestone-by-milestone sessions, there is constant pressure to "just ship this milestone" and come back to tests later. Tests later is tests never. TDD compounds across milestones: milestone N's tests catch milestone N+1's bugs. Skipping tests-first at milestone 2 to save twenty minutes costs you hours of debugging at milestone 5 — and the bugs shipped at milestone 5 are bugs you can no longer localize.
Enforce the gate at milestone boundaries even when it slows individual commits. This skill-level rule overrides session-level velocity pressure. If you find yourself reasoning "the user is waiting, I'll batch the tests after M5", you are the failure mode this section is designed to prevent.
0.6 Auditing TDD adherence from commit history
Self-reports of "I did TDD" are unreliable. Audit from git:
git log --format="%H %s" --name-status -- path/to/module.ex path/to/module_test.exs
Later test writing is a valuable defensive addition, but do NOT call it TDD and do NOT trust its coverage shape — tests-after tests tend to match the implementation rather than the intended behavior, so they pass even when the implementation is subtly wrong (see §3.8 BAD/GOOD).
Limitation of same-commit audits. When test and impl files appear in the same commit, the git audit can't distinguish tests-first from tests-after — both produce identical --name-status output. The author's recollection isn't auditable.
For auditable TDD evidence, commit the test file in a separate commit BEFORE the impl:
git add test/my_app/pricing_test.exs
git commit -m "test: Pricing.discount/2 — RED"
git add lib/my_app/pricing.ex
git commit -m "feat: Pricing.discount/2 — GREEN"
Git history then proves the ordering without relying on author claims. Use this discipline for high-stakes work (security-critical paths, payment flows, anything regulated). For ordinary feature work, in-commit TDD is fine — but in either case, the gate (§0.1) and milestone checklist (§0.3) apply.
0.7 When tests-after IS allowed
Tests-after is correct for a very narrow set: HEEx/EEx templates, CSS/styling, and one-off scripts outside lib//src/. That's it. A def in lib/ is production code — "glue code", "thin wrapper", and "it's just forwarding" are NOT valid exemptions. The review that catches bugs in untested code costs 10x more than the test that prevents them. Performance optimizations are tested via benchmarks (Benchee), not ExUnit — but the benchmark must exist.
1. Rules for Writing Elixir (LLM)
- ALWAYS pass the TDD gate (§0) before writing production code. No new public function is written without its failing test already on disk and confirmed red. This rule has priority over every other rule in this list — if the gate wasn't passed, STOP reading §1, go back to §0, write the test. See §3 for the workflow, §3.9 for TDD-specific rules, §3.3 for the narrow set of tests-after cases.
- ALWAYS reach for the decision table (§2) when choosing between
if, case, cond, with, and multi-clause functions. Structural dispatch = multi-clause; 2+ chained ok/error ops = with; boolean side-effect with no value = if; every common choice has a table row.
- NEVER use
if/else for structural dispatch. Multi-clause functions with pattern matching handle shape/type branching. if is only for a simple boolean guard with no value-returning else branch.
- NEVER use
try/rescue for expected failures. Return {:ok, _} / {:error, _} tuples and match them. Reserve rescue for genuine exceptional cases at system boundaries. For calling processes you don't own, prefer catch :exit.
- ALWAYS use
with to chain 2+ {:ok, _} / {:error, _} operations. Do not nest case statements. For a single operation, case is correct.
- NEVER write imperative loops. There are no
for/while loops with mutable state in Elixir. Use Enum.map / filter / reduce, for comprehensions, or tail recursion.
- NEVER rebind inside
Enum.each to accumulate — rebinding does not escape the anonymous function. Use Enum.map / Enum.reduce to collect results.
- ALWAYS design functions for pipe-ability. Data first; return transformed data; mutation APIs return the subject so callers can chain.
- NEVER pipe a single step.
name |> String.upcase() → String.upcase(name). Pipelines exist for 2+ transformations.
- NEVER end a pipeline with
|> case do if the pipeline is a single step. Assign the result to an intermediate variable, then case on it. Pipe-to-case is only idiomatic at the end of a genuinely multi-step pipeline.
- ALWAYS prefer pattern matching in function heads over
case in the body when dispatching on argument shape or type.
- ALWAYS use guard clauses to constrain function heads rather than validating inside the body.
- ALWAYS build strings with IO lists (
[a, ", ", b]) or interpolation ("#{a}, #{b}"), never by repeated <> concatenation in a loop (that's O(n²)).
- ALWAYS use
@spec on every public function and @doc / @moduledoc describing purpose — use @doc false / @moduledoc false for intentionally undocumented internals.
- ALWAYS put domain computation in pure functions. OTP callbacks MAY orchestrate side-effects (starting children, writing ETS, emitting telemetry, scheduling timers) — that's their job. But the computation driving those effects — discount math, validation logic, state-transition rules, policy checks — lives in pure modules that the callback delegates to. "Pure function" applies to the decision logic, not to every line inside a callback. See §8.7 Instructions Pattern (elixir-planning) when side-effect orchestration grows complex enough to be first-class.
- ALWAYS supervise long-running processes. Never
spawn / spawn_link for work that outlives its caller — use a Task.Supervisor, DynamicSupervisor, or permanent child under your app supervisor.
- ALWAYS choose the narrowest OTP construct. Preference order: pure function → struct module → Task → Agent → GenServer → gen_statem. Don't reach for GenServer when a pure function suffices. See §10.
- ALWAYS go through context modules. Controllers, LiveViews, CLI commands, GenServer callbacks, and scripts never call
Repo directly — they call Accounts.register_user/1, Catalog.get_product!/1, etc.
- ALWAYS use
@impl true on every behaviour callback implementation. It catches typos and missing callbacks at compile time.
- ALWAYS use
%{struct | key: val} for struct updates, not Map.put(struct, key, value). The update syntax raises on unknown keys, catching typos at compile time.
- ALWAYS use the latest stable dependency versions and follow the library's recommended
mix.exs setup. Don't hand-craft configurations that would break the standard installation flow.
- ALWAYS run
mix format, mix credo --strict, and the test suite before declaring a change done. Fix warnings; do not suppress them.
- ALWAYS treat
with chains as Elixir's railway. When a function performs 2+ ok/error operations, the with chain is the railway-oriented form: each <- is a track switch, the success path is straight-line, and a failure at any step short-circuits with that step's error returned unchanged. PREFER bare with (no else) — it propagates errors transparently and stays LCO-safe. Add else ONLY to translate error shapes for callers; never to handle the success case. When multiple steps return the same {:error, _} shape and you need to know which step failed, use the tagged-tuple with pattern ({:fetch, {:ok, x}} <- {:fetch, fetch(id)}). Chain length signals architectural drift: 2–4 steps healthy, 5–6 approaching limit, 7+ split into named phases. See §5.10.1–§5.10.4 for the templates.
- ALWAYS distinguish short-circuit (
with) from accumulating validation. with short-circuits on first error — correct for sequentially-dependent operations. For independent validations whose errors should ALL be reported (form fields, batch import, multi-rule check), use the error-accumulating reduce pattern from §5.10.6, not with. Anti-pattern: form validation that uses with so the user fixes one field, resubmits, sees the next error, fixes it, resubmits, etc. — accumulate instead.
- ALWAYS pass capabilities (clock, random, config, secrets) as arguments — NEVER read them inside building-block functions. A building-block that calls
DateTime.utc_now/0, Application.get_env/2, :rand.uniform/0, or :persistent_term.get/1 fails axis 1 (input closure) of the building-block checklist. Take the value as an argument; let the orchestrator resolve it at call time. Bundle multiple capabilities into a ctx struct when the count exceeds 2 (§5.10.8). Behaviour-based DI is a degenerate form of capability passing: appropriate for 2–3 implementations chosen at boot, not for 5+ runtime-varying capabilities.
- ALWAYS keep the data (subject) as the FIRST argument in public functions. This is the foundation of pipeline composition.
def discount(price, rate) not def discount(rate, price). The stdlib observes this rigorously (Enum.map(coll, fn), String.replace(s, pat, rep), Map.put(map, k, v)). Configuration / opts go last. Functions on pairs go: both subjects first, then opts (Map.merge(a, b, conflict_fn)). A non-subject-first function silently breaks every downstream pipeline; renaming arguments is a 5-minute refactor that pays off forever (§5.10.10).
- PREFER
update_in / put_in / get_in over nested Map.update / Map.put chains. Two levels of nested update is the threshold; beyond that, an Access path is more readable and composable (§5.10.9). For domain structs that need deep updates, either defimpl Access or split the operation across helpers. Reach for Pathex / Focus only when paths are dynamic or 5+ levels deep — the threshold is high; update_in covers most cases.
- ALWAYS check the SSOT source before introducing a new magic literal. Before you type
@timeout 5_000 in a module, or %{role: "admin"} in a changeset, or Map.get(opts, :timeout, 30_000) in a helper, grep the project for config/config.exs / config/runtime.exs / lib/MY_APP/constants.ex / any MyApp.Config-style module — if a name for this value already exists there, use it. If one doesn't but this value encodes a deliberate policy (timeout, retry count, role name, allowed set), add it THERE first and reference from here. Literals inline in business logic drift — the reviewing skill's SSOT litmus ("if this fact changes, how many files do I have to update?") should answer 1, not N. Common Elixir SSOT homes: config/*.exs (for values that may change per environment), a dedicated MyApp.Config module (for values read on the hot path — see §10.5.1 elixir-planning), module @attributes (for values that are compile-time constants of a single module). The anti-slop elixir-magic-literal-outside-config check fires post-write as a backstop; this rule is the proactive version.
- NEVER deserialize untrusted input via bare
:erlang.binary_to_term/1,2. ETF can instantiate any term — atoms, funs, pids — so on attacker-controlled bytes it is RCE-equivalent. Use Plug.Crypto.non_executable_binary_to_term/2 (rejects funs/pids/refs at decode time) and pass [:safe] when atoms must already exist. For external payloads (HTTP body, message broker, file upload), prefer JSON + a typed DTO via MyDTO.new/1 — see §5.12. Plug's session cookie store and Phoenix.Token both use the non_executable_binary_to_term/2 wrapper; bare :erlang.binary_to_term/1 does not appear in any production module of Plug or Phoenix.
- NEVER call
Code.eval_string/1,2, Code.eval_quoted/1,2,3, or Code.compile_string/1,2 on runtime data. These are build-time primitives (Mix tasks, code generators). On runtime input they are unconditionally RCE — there is no :safe form. Replace with a bounded command/plugin registry: a compile-time @commands %{name => &Mod.fun/n} map and Map.fetch(@commands, name) for dispatch. See §5.11. Phoenix, Plug, Plug.Crypto, Ecto, and Bandit do not call Code.eval_* from lib/; if the rule fires on a candidate edit, the edit is wrong.
- NEVER call
apply(mod, fun, args) where mod or fun can be reached by external input. A variable module/function in apply whose value flows from conn.params, a channel message, an Oban job arg, or a GenServer message payload is a confused-deputy RCE primitive. Plug uses apply(mod, fun, args) extensively (Plug.Parsers.JSON, Plug.RewriteOn, Plug.SSL) — but mod/fun in those calls come from init/1-validated config tuples ({module, function, args}), never from request data. The rule is about taint: if the variable's source is config or a compile-time map, fine; if it can be reached by request input, replace with a bounded registry (§5.11).
- ALWAYS propagate
Logger.metadata across async boundaries. Logger.metadata is documented as process-local (see Logger module docs): a process spawned via Task.async, Task.Supervisor.start_child, Task.async_stream, or spawn_link starts with empty metadata. Any log line or :telemetry.execute/3 call from that process is missing the parent's request_id, trace_id, and tenant_id — the line becomes orphan in log search. Capture metadata before the async call, restore it inside the closure (see §5.13). For request-scope metadata setup, Plug.RequestId is the reference setter (Logger.metadata([{logger_metadata_key, request_id}])).
- ALWAYS attach a safe
Inspect rendering to every struct that carries a secret field. Either @derive {Inspect, only: [...]} (listing the safe fields) before defstruct, OR defimpl Inspect, for: __MODULE__ for full custom rendering. Without an override, crash dumps include process state in SASL reports, observer, and remote shells — every secret field is printed verbatim. The reference is Plug.Conn's defimpl Inspect (lib/plug/conn.ex), which replaces :secret_key_base with :... before Inspect.Any.inspect/2. See §5.14.
- NEVER include
__STACKTRACE__ in a value that crosses a response boundary (Phoenix conn render, channel reply, GraphQL resolver result, JSON view, LiveView flash). Stacktraces leak the internal module structure of the application — private modules, line numbers, library versions, call paths. They are a roadmap for an attacker. Drain them via Logger.error(Exception.format(:error, e, __STACKTRACE__)) or :telemetry.execute/3 metadata; return a sanitized error tuple ({:error, :payment_failed}) which the boundary maps to a bounded response shape (%{code: "payment_failed"}). Reference: Phoenix.Endpoint.RenderErrors.__catch__/5 captures stack = __STACKTRACE__ and passes it to instrument_render_and_send (Logger / telemetry) — never to the response body. See §5.15.
- NEVER leave
IO.inspect/1,2 or dbg/0,1 in lib/. Production lib code does not debug-print to stdout. Phoenix, Bandit, Plug.Crypto, and Guardian all have ZERO IO.inspect calls in their published lib/. The only legitimate lib-side appearances are: a deliberate public API that exposes IO.inspect-shape behaviour to users (e.g. Ecto.Multi.inspect/3 is intentional), or # IO.inspect ... shapes inside doc comments. If you reach for IO.inspect while writing production code, you wanted Logger.debug/info/warning with structured metadata.
- ALWAYS plan for event/command schema evolution from day one. Once an event or command is persisted (event store) or exchanged across a versioned wire (Oban args, broker payloads), adding a field or renaming one is a breaking change against existing data. Pick ONE convention per project, write it down at planning time: (a) inline
:version field on every event struct, OR (b) defimpl Commanded.Event.Upcaster, for: MyEvent that transforms older persisted shapes into the current shape in place. The Commanded-shape uses one event module per type (NOT separate V1.OrderPlaced / V2.OrderPlaced modules) — the upcaster fills in defaults for missing fields when an older event is read. See §5.16.
2. Master "Which Construct?" Decision Guide
This is the single most important section to consult at the moment of writing. Each row maps an intent (what you're trying to do) to the idiomatic construct and the common anti-pattern to avoid. Read left-to-right when you're about to type code: "I need to X" → use Y, not Z.
2.1 Control flow
| When you need to... | Use this | NOT this |
|---|
| Branch on the shape of data (struct type, tuple tag, map keys) | Multi-clause function with pattern in head | if is_struct(x, Mod) / case ... do |
| Branch on membership in a compile-time list/set | Multi-clause with when x in @list guard + catch-all | if x in @list, do: yes(), else: no() |
| Branch on a computed value (size, length, type check) | Multi-clause with guard on the computation | case byte_size(v) do n when ... -> ... end |
Chain 2+ {:ok, _} / {:error, _} operations | with ... do ... else ... end | Nested case, nested if |
| Handle a single ok/error result | case ... do | with with one clause, if |
| Boolean side-effect, no value returned | if cond, do: side_effect() | case bool do true -> ...; false -> ... end |
| Boolean branch, both paths return values | case bool do true -> ...; false -> ... end | if/else (truthy, not strict) |
| Multiple boolean conditions (else-if chain) | cond do ... end | Nested if/else |
| Dispatch on value range / thresholds | cond do or multi-clause with guards | if a < x, do: ...; if x < b, do: ... |
| Early-exit from a reducer | Enum.reduce_while/3 with {:cont, acc} / {:halt, acc} | Enum.reduce with throw/catch |
| Lookup-then-act with fallback | Multi-clause function or case Map.fetch/2 | map[:key] != nil check |
Check for nil | Multi-clause on the value, or case on Map.fetch/2 | if x == nil / if is_nil(x) |
| Dispatch on one field of a large struct | Pattern-match just that field, or guard struct.field | Destructure whole struct in head |
| Execute a block conditionally in a pipeline | then(&if/1) or a maybe_X/N helper | Break the pipeline with case |
| Exit early on first error in a pipeline | with chain | Enum.reduce_while + case on result |
2.2 Collection operations
| When you need to... | Use this | NOT this |
|---|
| Transform each element | Enum.map/2 with function capture &fun/1 | Enum.map(xs, fn x -> fun(x) end) |
| Filter a list by predicate | Enum.filter/2 | Enum.reduce that conditionally conses |
| Filter AND transform in one pass | for x <- xs, pred.(x), do: transform(x) | xs |> Enum.filter(pred) |> Enum.map(t) |
| Reduce to single value | Enum.reduce/3 | manual recursion |
| Build a map from an enumerable | Map.new/2 or for x <- xs, into: %{}, do: {k, v} | Enum.reduce(xs, %{}, fn ... end) |
| Build a MapSet | MapSet.new/1,2 or for ..., into: MapSet.new() | Enum.reduce into a list then dedupe |
| Build a concatenated binary | IO list + IO.iodata_to_binary/1, or Enum.map_join/3 | Enum.reduce(..., "", &<>/2) — O(n²) |
| Early-exit accumulation | Enum.reduce_while/3 | throw/catch, flag variable |
| Iterate with index | Enum.with_index/1,2 | for i <- 0..length(xs)-1 |
| Process items in parallel (side effects) | Task.async_stream/3,5 with ordered: false | Enum.map(&Task.async/1) |> Enum.map(&Task.await/1) |
| Dedupe by key | Enum.uniq_by/2 | MapSet + manual loop |
| Partition by predicate | Enum.split_with/2 | Two Enum.filter passes |
| Group by derived key | Enum.group_by/2,3 | Enum.reduce into Map.update |
| Chunk into batches | Enum.chunk_every/2,4 | manual recursion |
| Count by frequency | Enum.frequencies/1 / Enum.frequencies_by/2 | Enum.group_by + map_size per bucket |
| Process large/infinite data lazily | Stream.* + one Enum.* at the end | Enum.* on full collection |
| Pattern-match while iterating (silent skip on mismatch) | for {:ok, v} <- results, do: v | Enum.filter(...) |> Enum.map(...) |
2.3 Pattern matching and dispatch
| When you need to... | Use this | NOT this |
|---|
| Extract a field from a struct | Pattern match: def f(%User{name: name} = u) | u.name (fine for access, not for asserting presence) |
| Assert a map key exists | Match: %{key: v} = map or in head | Map.get(map, :key) || raise |
| Match against an existing variable | Pin: case x do ^expected -> ... | Bare name (rebinds!) |
| Check non-empty list | match?([_ | _], xs) or pattern in head | length(xs) > 0 (O(n)) |
| Check empty map | map == %{} or map_size(map) == 0 | %{} = map (matches ANY map) |
| Match JSON / params (string keys) | %{"key" => v} = params | %{key: v} — atom ≠ string |
| Match internal data (atom keys) | %{key: v} = internal_map | %{"key" => v} |
| Constrain by type / range in a head | Guard clause: when is_integer(n) and n > 0 | Body if + validation |
Match against a 0 or nil base case | Separate clause: def f(0), def f(nil) | Body if x == 0 |
2.4 Error handling
| Situation | Use |
|---|
| Can you check the condition BEFORE the call? | Check first (Process.whereis/1, Map.fetch/2) |
| Calling a process you don't own? | try ... catch :exit, _ at the boundary |
| Input from untrusted / external source? | rescue specific exception at the boundary (e.g. :erlang.binary_to_term on network bytes) |
| Error is an expected business case? | Return {:ok, _} / {:error, _} from the function |
| Everything else? | Let it crash — the supervisor handles it |
| When you need to... | Use this | NOT this |
|---|
| Return success + data | {:ok, value} | value (can't distinguish from nil / :ok) |
| Return failure with a reason | {:error, reason} (atom or struct) | nil, raise, boolean false |
| Side-effect success (no data) | :ok | {:ok, nil} |
| Fail-fast on wrong input in a script | Bang variant (File.read!/1) | case + raise |
| Offer both strict and lenient API | Pair: fetch/1 (ok/error) + fetch!/1 (raises) | Only bang, or only non-bang |
| Wrap an external library that raises | try/rescue at the adapter boundary, convert to ok/error | Let exceptions leak out of your context |
| Propagate unknown errors | Let them crash; supervisor restarts | Catch-all rescue _ |
Decode an ETF (:erlang.term_to_binary) payload from another node or signed cookie | Plug.Crypto.non_executable_binary_to_term(payload, [:safe]) | Bare :erlang.binary_to_term/1 (RCE class on attacker bytes) |
| Decode a payload from outside the cluster (HTTP body, broker, upload) | JSON / Protobuf / explicit format + MyDTO.new/1 (see §5.12) | Any form of :erlang.binary_to_term |
| Dispatch on a string command from external input | Map.fetch(@commands, name) against a compile-time registry (see §5.11) | apply(String.to_existing_atom(name), :run, args) / Code.eval_* |
| Need to invoke a function whose name is config-supplied | apply(mod, fun, args) is fine when mod/fun came from init/1-validated config (Plug pattern) | Same apply shape with mod/fun reaching from request params or channel messages |
| Chain 2+ ok/error operations | Bare with (railway, no else) — §5.10.1 | Nested case, if |
| Translate error shapes for callers | with + targeted else clauses — §5.10.2 | else that handles success values |
| Distinguish which step failed when shapes overlap | Tagged-tuple with ({:fetch, {:ok, x}} <- {:fetch, fn()}) — §5.10.3 | Catch-all error and reverse-engineer the cause |
| Validate independent fields, accumulate errors | Reduce that collects errors into a list — §5.10.6 | with (short-circuits — wrong UX for forms) |
| Transform success value, leave error untouched | with {:ok, v} <- f(), do: {:ok, transform.(v)} — §5.10.5 | case ... do {:ok, v} -> {:ok, transform.(v)}; e -> e end |
| Communicate "this should happen" without doing it | Return events list {:ok, value, [events]} — §5.10.7 | Inline Logger/Repo/PubSub from a building-block |
2.5 Strings and binaries
| When you need to... | Use this | NOT this |
|---|
| Build a string from parts | Interpolation "#{a} and #{b}" | a <> " and " <> b |
| Build a string in a loop | IO list + IO.iodata_to_binary/1 | Enum.reduce(xs, "", &<>/2) |
| Join list into string with separator | Enum.join(xs, ", ") or Enum.map_join/3 | Enum.reduce with <> |
| Parse a known binary layout | Binary pattern matching <<a::8, b::16, rest::binary>> | String.split on byte boundaries |
| Parse a known-shape header / fixed prefix (auth header, port-prefix, length-prefix) | Binary pattern matching: <<"Bearer ", token::binary>> | Regex.run(~r/Bearer\s+(.+)/, header) — slower, recompiled per call |
| Split on a single-character delimiter | String.split(value, ":", parts: 2) | Regex.run(~r/^([^:]+):(.+)$/, value) |
| Parse a real grammar (TLS handshake, DSL, query language) | NimbleParsec parser combinator | A regex chain; or a hand-written recursive descent that grows uncontrollably |
| Convert integer to string | Integer.to_string/1,2 | "#{n}" (allocates, slower) |
| Convert to atom from user input | String.to_existing_atom/1 | String.to_atom/1 (exhausts atom table) |
| Coerce unknown-type value for display | inspect/1 | to_string/1 (raises for some types) |
| Compare case-insensitively | String.downcase/1 both sides | manual String.equivalent? check |
2.6 Data updates
| When you need to... | Use this | NOT this |
|---|
| Update an existing struct field | %{struct | field: value} | Map.put(struct, :field, value) |
| Update an existing map key (known present) | %{map | key: value} | Map.put(map, :key, value) |
| Set / create a map key (maybe absent) | Map.put(map, key, value) | %{map | key: value} (raises if absent) |
| Update a nested field | put_in(data, [path], value) or update_in/3 | Manual get-modify-put chain |
| Increment a counter in a map | Map.update(map, :k, 1, & &1 + 1) | Get + 1 + Put |
| Merge two maps (right wins) | Map.merge/2 | Enum.reduce(other, map, & Map.put(&2, ...)) |
| Merge with custom conflict resolution | Map.merge/3 | Manual reduce |
| Delete a key | Map.delete/2 | Map.drop(map, [key]) for one key |
| Check key presence (nil is a valid value) | Map.has_key?/2 or Map.fetch/2 | map[:key] != nil |
2.7 Function design
| When you need to... | Use this | NOT this |
|---|
| Expose a function unchanged from another module | defdelegate name(args), to: Other | def name(args), do: Other.name(args) |
| Accept optional config | Keyword list last arg + Keyword.validate!/2 | Multiple overloads with many args |
| Provide a default for an arg | def f(x, opts \\ []) | Multiple clauses setting defaults |
| Return transformed data in a pipeline | First arg = data, return new data | Mutation-style (non-existent in Elixir) |
| Chain mutation-like configuration | Return the subject: mock |> expect(...) |> allow(...) | Separate config_X / config_Y calls |
| Implement a callback from a behaviour | Mark with @impl true above the function | Bare def (loses compile-time check) |
| Disambiguate multiple behaviours | @impl SomeBehaviour | @impl true when ambiguous |
2.8 Module boundaries
| When you need to... | Use this | NOT this |
|---|
| Expose domain API from a context | def with @doc + @spec | Thin wrappers that forward to internal modules unchanged |
| Hide an internal helper | defp | def + @doc false (still callable) |
| Swap implementations (test/prod) | @callback behaviour + Application.compile_env | if Mix.env() == :test |
| Provide data-level polymorphism | Protocol (defprotocol + defimpl) | Giant case on struct type |
| Reuse a default implementation | use Module with defoverridable | Copy-paste |
| Share constants across modules | Module with defmacro or def returning value | Global mutable state |
2.9 Process and concurrency
| When you need to... | Use this | NOT this |
|---|
| Fire-and-forget async side effect | Task.Supervisor.start_child/2 | spawn/1 (unsupervised) |
| Await parallel results | Task.async_stream/3,5 | Manual Task.async + Task.await list |
| Long-running stateful worker | GenServer | Infinite-loop spawn |
| Shared read-heavy state (no single writer bottleneck) | ETS (:public, read_concurrency: true) | GenServer.call for every read |
| Cross-process counter | :counters / :atomics | GenServer.call for +1 |
| Rarely-changing global config | :persistent_term | Application.get_env on hot path |
| Serialize access to an external resource | GenServer (one writer) | Multiple processes racing to the resource |
| Explicit state machine with transitions | :gen_statem | GenServer with large case on state |
| Supervise dynamically created workers | DynamicSupervisor + Registry | Named GenServers per entity |
| Scheduled / periodic work | Process.send_after loop, or Oban for persistence | :timer.sleep in a loop |
| Pub/sub within a node | Registry with :duplicate keys, or Phoenix.PubSub | Process.send to a list of pids you maintain |
| Backpressured pipeline | GenStage / Broadway | Manual message passing |
| Optional call to a maybe-missing process | GenServer.whereis/1 + try ... catch :exit | GenServer.call without guard |
2.10 Pipelines
| When you need to... | Use this | NOT this |
|---|
| Apply 2+ transformations | Pipeline: data |> step1() |> step2() | step2(step1(data)) (less readable for chains) |
| Apply exactly 1 function | Direct call: String.upcase(name) | name |> String.upcase() |
| Branch at the end of a pipeline (multi-step) | Pipe into case: ... |> case do ... | Assign to var, then case |
| Branch after a single call | Assign to result, then case | single_call() |> case do (single-step pipe) |
| Optionally apply a step | maybe_X/2 helper: multi-clause with true/false arg | if inside the pipeline |
| Inspect without changing value | tap(&IO.inspect/1) | Assign to var, inspect, reuse |
| Transform for a single non-pipable step | then/2: data |> then(&some_fn.(&1, extra)) | Break pipeline, assign, call, re-enter |
| Log / emit telemetry mid-pipeline | tap(&Logger.info/1) | Pipeline break |
| Define a public function so callers can pipe into it | First arg = data; opts last (def f(data, opts \\ [])) — §5.10.10 | First arg = opts; data later (breaks every pipeline) |
| Compose deep nested updates | update_in(struct.a.b.c, &fn/1) / put_in/2 — §5.10.9 | Nested Map.update lambdas (>2 levels) |
2.11 Testing
| When you need to... | Use this | NOT this |
|---|
| Test a pure function | ExUnit test with input → expected output | Setup start_supervised! when not needed |
| Test the happy path | assert {:ok, value} = function(...) | assert function(...) == {:ok, ...} (worse failure messages) |
| Test an error case | assert {:error, _} = function(bad) | assert_raise unless the function really raises |
| Test a changeset error | errors_on/1 helper: %{field: ["msg"]} = errors_on(cs) | Dig into cs.errors manually |
| Mock an external service | Define @callback → Mox.defmock → expect | Monkey-patch module, redefine at runtime |
| Isolate DB writes per test | Ecto.Adapters.SQL.Sandbox with async: true | Truncate tables between tests |
| Test a GenServer | Test the client API (MyServer.call/1) against a start_supervised! instance | Call handle_call directly |
| Wait for an async message | assert_receive pattern, 500 | Process.sleep(500) && assert ... |
| Use fresh data per test | Factory (insert(:user)) | Fixture module with shared instances |
| Property test invariants | StreamData with check all | Hand-generate edge cases |
| Assert function shouldn't be called | refute_called equivalent: Mox.expect N=0 | Omit and hope |
3. TDD Workflow — Red / Green / Refactor
Testing is not a phase that happens after writing code. It is the loop you code inside. Every change to behavior is driven by a failing test.
3.1 The core cycle
- RED — Write a failing test that describes the behavior you want. Run it. Confirm the failure message matches your expectation (if the test passes immediately, the test is wrong or the behavior already exists).
- GREEN — Write the minimum code that makes the test pass. Do not gold-plate. Resist the urge to handle edge cases that aren't in a test yet — write those tests first.
- REFACTOR — With tests green, improve the code: extract helpers, rename, remove duplication, tighten types. Re-run tests after each structural change.
- Go back to RED for the next behavior.
3.2 Canonical TDD example
# STEP 1 — RED: Write the test first (MyApp.Pricing does not yet exist)
defmodule MyApp.PricingTest do
use ExUnit.Case, async: true
describe "discount/2" do
test "applies a percentage discount" do
assert MyApp.Pricing.discount(100_00, 0.10) == 90_00
end
test "clamps the discount to the item price (never negative)" do
assert MyApp.Pricing.discount(50_00, 1.50) == 0
end
test "returns price unchanged for a zero discount" do
assert MyApp.Pricing.discount(75_00, 0.0) == 75_00
end
end
end
# Run: mix test test/my_app/pricing_test.exs — ALL THREE FAIL (module undefined)
# STEP 2 — GREEN: Minimum implementation
defmodule MyApp.Pricing do
@spec discount(non_neg_integer(), float()) :: non_neg_integer()
def discount(price_cents, rate) when rate >= 0 do
max(0, price_cents - round(price_cents * rate))
end
end
# Run again — three tests pass.
# STEP 3 — REFACTOR: No duplication yet, spec is correct, naming clear.
# Nothing to clean up. Move on to the next test case.
3.3 Decision: write tests first or after?
| Write tests FIRST | Write tests AFTER | Skip tests |
|---|
| New public API function | UI / LiveView layout changes | One-off scripts with no reuse |
| Bug fix (reproduce bug as failing test first) | HEEx template tweaks | Exploratory prototyping / spikes |
| Business logic with rules and edge cases | Performance optimization (benchmark instead) | Temporary debug output |
| Refactor (write characterization tests of current behavior first) | Pure visual CSS changes | Throwaway migrations to data format |
| Any changeset validation | | |
Any multi-step with chain | | |
| Any function that crosses a context boundary | | |
Default: tests first. The cost of "write test after" is usually missing edge cases; the cost of "write test first" is about 20 seconds of extra typing.
3.4 Outside-in TDD
Start from the public context API; let the test failures guide you inward into the private helpers.
# 1. Write a context-level test FIRST (mock external boundaries with Mox)
test "register/1 creates user, sends welcome email, emits :user_registered event" do
Mox.expect(MyApp.Mailer.Mock, :send_welcome, fn %User{email: "a@b.com"} -> :ok end)
assert {:ok, %User{email: "a@b.com"}} =
Accounts.register(%{email: "a@b.com", password: "secret-pw-123"})
assert_receive {:user_registered, %User{email: "a@b.com"}}
end
# 2. This test tells you the shape of Accounts.register/1 — its inputs, outputs, side effects
# 3. Implement register/1 as a context function. It probably uses:
# - a changeset (write the changeset function → covered by changeset tests)
# - Repo.insert (not mocked — uses DB via sandbox)
# - the mailer behaviour (mocked)
# - PubSub / Phoenix.PubSub.broadcast (may or may not be mocked)
# 4. Write unit tests for any extracted helpers as you go — each helper gets its own red/green cycle
3.5 Property-based TDD
For invariant-driven code, define the invariant before the implementation.
use ExUnitProperties
# Invariant 1: encoding is reversible
property "encode then decode is the identity" do
check all value <- term() do
assert value == value |> MyCodec.encode() |> MyCodec.decode() |> elem(1)
end
end
# Invariant 2: sort preserves length and orders ascending
property "sort output is always ordered and same length as input" do
check all list <- list_of(integer()) do
sorted = MySort.sort(list)
assert length(sorted) == length(list)
assert sorted == Enum.sort(list)
end
end
Properties are particularly strong for: parsers, serializers, compression, sorting, set operations, anything with an obvious mathematical inverse or invariant.
3.6 Bug-fix TDD
The strongest form of TDD: reproduce every bug as a failing test before fixing it.
1. User reports: "deleting a user with orphan posts crashes."
2. Write a test that creates a user, creates posts, deletes the user, and asserts the expected behavior (e.g., {:error, :has_orphans} or cascade delete).
3. Confirm the test fails with the actual bug (MatchError / FunctionClauseError / etc.).
4. Fix the code. Test goes green.
5. Commit: both the bug-reproducing test AND the fix. The test prevents regression.
3.7 Fast feedback loops while iterating
mix test test/my_app/pricing_test.exs:18
mix test --failed
mix test --stale
mix test --max-failures 1
mix test.watch
3.8 TDD anti-patterns (BAD/GOOD)
# BAD — Testing implementation details (brittle, breaks on refactor)
test "Accounts.register calls Repo.insert" do
# Asserting the internal function sequence. Refactoring breaks this
# test even when the behavior is unchanged.
end
# GOOD — Test the observable behavior
test "Accounts.register persists the user and returns {:ok, user}" do
assert {:ok, %User{id: id}} = Accounts.register(@valid_attrs)
assert Repo.get(User, id)
end
# BAD — Writing the implementation first, then reverse-engineering tests.
# Tests become tautological: they assert exactly what the code does, not what it should do.
def calculate_total(cart), do: Enum.sum(Enum.map(cart.items, & &1.price))
test "calculate_total returns Enum.sum(Enum.map(cart.items, & &1.price))" do
# Rubber stamp. Catches nothing.
end
# GOOD — Test describes WHAT should happen; implementation describes HOW.
test "calculate_total sums item prices" do
cart = %Cart{items: [%Item{price: 100}, %Item{price: 250}]}
assert calculate_total(cart) == 350
end
test "calculate_total is zero for an empty cart" do
assert calculate_total(%Cart{items: []}) == 0
end
# BAD — Mocking what you don't own (internal modules), so mocks lie
Mox.defmock(MyApp.Pricing.Mock, for: MyApp.Pricing) # You own Pricing — test it directly!
# GOOD — Mock only at system boundaries (external APIs, databases via sandbox, mailers, payment gateways)
Mox.defmock(MyApp.Mailer.Mock, for: MyApp.Mailer) # Good: mailer crosses the process + network boundary
# BAD — One test asserting five unrelated things (fails cascade, hard to diagnose)
test "user flow" do
# create user, send email, update profile, delete, verify audit log
end
# GOOD — Small focused tests, one behavior each
describe "register/1" do
test "creates a user with hashed password" do ... end
test "sends a welcome email" do ... end
test "emits a :user_registered event" do ... end
end
3.9 TDD rules (LLM)
- ALWAYS pass the TDD gate (§0) before writing a new public function. Run the test before implementing — confirm red is red for the right reason. This is Rule 1 of §1 restated; it fires at a higher abstraction level than any other TDD rule.
- ALWAYS write the minimum to go green. Write the next test before generalizing.
- NEVER test private functions directly — test via the public API. If the private is complex enough to test independently, it belongs in its own module.
- NEVER assert implementation details (which internal function was called, in what order). Assert observable behavior.
- ALWAYS reproduce every bug as a failing test before fixing. The test is the regression guard. See §0.4 bug-fix retrospective — shipping a fix without a regression test is not a fix.
- PREFER many small
describe blocks over long flat test modules. Group by function-under-test.
- PREFER
async: true on every test module that does not touch shared global state (named GenServers, global application env, :global registrations). Ecto sandbox is async-safe.
- ALWAYS enforce test-first at milestone boundaries in long or autonomous sessions (§0.5). Milestone velocity pressure biases toward tests-after; the TDD gate overrides that pressure. TDD compounds across milestones — one milestone's tests catch the next milestone's bugs.
- NEVER trust self-reports of TDD adherence — audit from git (§0.6). If the test file appears in a commit after the implementation file, TDD did not happen for that module regardless of current coverage. Later tests tend to match the implementation rather than the intended behavior.
4. Testing Essentials
This section gives you everything you need for daily testing. For deep LiveView / channel testing, property-testing generators beyond the basics, ExVCR, or Wallaby browser tests, load elixir-testing.
Depth: For complete ExUnit/Mox/sandbox/factory templates + LiveView/Channel/Oban helpers, load testing-patterns.md. For test strategy (pyramid, mock boundaries), load ../elixir-planning/test-strategy.md.
4.1 Test case templates — pick the right one
| Template | When to use | async safe? |
|---|
ExUnit.Case | Pure unit tests (no DB, no Phoenix) | Yes |
MyApp.DataCase | Anything that hits the database via Repo | Yes (with Sandbox) |
MyAppWeb.ConnCase | Controller, plug, JSON API tests | Yes (with Sandbox) |
MyAppWeb.ChannelCase | Phoenix channel tests | Yes |
MyAppWeb.LiveViewCase (or reuse ConnCase) | LiveView tests | Yes |
defmodule MyApp.AccountsTest do
use MyApp.DataCase, async: true # <-- DB-backed, parallel-safe
import MyApp.Factory # ExMachina factories
alias MyApp.Accounts
describe "register/1" do
# tests here
end
end
4.2 Which assertion? — decision table
| When you need to... | Use this | NOT this |
|---|
| Assert function returned success + shape | assert {:ok, %User{id: id}} = fun(...) | assert match?({:ok, %User{}}, fun(...)) (no diff on failure) |
| Assert specific equality | assert x == y | assert x === y unless you need strict 1 !== 1.0 |
| Assert value is truthy / falsy | assert value / refute value | assert value == true |
| Assert substring or regex match | assert x =~ "substr" / assert x =~ ~r/pat/ | assert String.contains?(x, "substr") |
| Assert membership | assert x in [:a, :b] or assert x in 1..10 | assert Enum.member?([...], x) |
| Assert float equality | assert_in_delta 0.1 + 0.2, 0.3, 1.0e-6 | assert 0.1 + 0.2 == 0.3 (false!) |
| Assert function raises | assert_raise ArgumentError, fn -> parse!("bad") end | try/rescue in the test |
| Assert a message arrived | assert_receive {:event, _}, 500 | Process.sleep(500) then check mailbox |
| Assert a message did NOT arrive | refute_receive :x, 100 | Inspect mailbox manually |
| Assert a log line was produced | assert capture_log(fn -> ... end) =~ "msg" | Check Logger state |
| Assert changeset error message | assert %{field: ["msg"]} = errors_on(cs) | Dig into cs.errors manually |
| Explicit unreachable branch | flunk("should not happen") | assert false |
Why pattern-match assertions win:
# GOOD — pattern match extracts and asserts shape, best failure messages
assert {:ok, %User{id: id, email: "a@b.com"}} = Accounts.create_user(valid_attrs)
# Now `id` is bound for use below
# OK but worse failures
assert match?({:ok, %User{email: "a@b.com"}}, Accounts.create_user(valid_attrs))
# BAD — stringified inspect, no structural diff
assert inspect(result) == "{:ok, %User{email: \"a@b.com\"}}"
4.3 Setup patterns
# Basic — return a context map merged into each test's context
setup do
%{user: insert(:user), product: insert(:product)}
end
test "ships order", %{user: user, product: product} do
# use user, product
end
# Named setup functions (shared across describes)
setup [:create_user, :verify_on_exit!]
defp create_user(_context), do: %{user: insert(:user)}
# Setup with @tag access
setup tags do
if tags[:admin] do
%{user: insert(:admin)}
else
%{user: insert(:user)}
end
end
@tag :admin
test "admin can delete", %{user: user} do ... end
# start_supervised! — process is auto-stopped when the test ends
setup do
pid = start_supervised!({MyWorker, initial_state: []})
%{worker: pid}
end
# setup_all — runs ONCE per module, not per test (use sparingly — breaks async isolation)
setup_all do
start_supervised!({Phoenix.PubSub, name: TestPubSub})
%{pubsub: TestPubSub}
end
# on_exit — cleanup hook, runs after each test even on failure
setup context do
:telemetry.attach("#{context.test}", @events, &handler/4, nil)
on_exit(fn -> :telemetry.detach("#{context.test}") end)
:ok
end
# Temp directory — ExUnit creates and cleans up
@tag :tmp_dir
test "writes a file", %{tmp_dir: tmp_dir} do
File.write!(Path.join(tmp_dir, "test.txt"), "hello")
end
4.4 Mox — mocking system boundaries
Mox is the official, Dialyzer-safe, async-safe mocking library. Always use Mox; never :meck, never monkey-patch, never redefine modules at runtime.
What should (and shouldn't) be mocked:
| Boundary type | Mock? | Example |
|---|
| External network service | Yes | HTTP client, payment gateway, S3, SendGrid |
| OS process / port (email, push notification) | Yes | Mailer, FCM / APNS sender |
| Non-determinism you don't control | Yes | Clock, random, UUID generator |
| Your own domain modules (Accounts, Pricing, Orders) | No | Test directly — mocking your own code makes tests lie |
Database via Repo | No | Use Ecto.Adapters.SQL.Sandbox (real DB, isolated) |
| Phoenix.PubSub | No | Use real PubSub in tests — it's fast and deterministic |
| Private helpers inside the same module | No | Test via the public API |
Which Mox API? — decision table:
| When you need to... | Use this | NOT this |
|---|
| Assert a function WAS called, verify call count and args | expect(Mock, :fn, fn args -> ret end) | stub (no verification) |
| Assert a function was called exactly N times | expect(Mock, :fn, N, fn args -> ret end) | expect + counting |
| Allow any number of calls (incl. zero), no verification | stub(Mock, :fn, fn args -> ret end) | expect for "maybe called" |
| Stub all callbacks of a behaviour from a real impl | stub_with(Mock, RealImplementation) | Many individual stub/3 calls |
| Assert a function must NOT be called | expect(Mock, :fn, 0, fn _ -> flunk("...") end) | Just omit (no guarantee) |
| Let a spawned process use the current test's mocks | allow(Mock, self(), pid) | set_mox_global() if you can avoid it |
| Lazy pid resolution (process started later) | allow(Mock, self(), fn -> GenServer.whereis(Name) end) | Eager pid lookup |
| Run tests that spawn processes across multiple testers | set_mox_global() — requires async: false | Trying to chase pids with allow/3 |
Canonical Mox setup (the whole pattern in one example):
# 1. Behaviour — the contract
defmodule MyApp.Mailer do
@callback send_welcome(User.t()) :: :ok | {:error, term()}
end
# 2. Real impl (behind a behaviour)
defmodule MyApp.Mailer.Swoosh do
@behaviour MyApp.Mailer
@impl true
def send_welcome(user), do: # ... SMTP ...
end
# 3. test_helper.exs
Mox.defmock(MyApp.Mailer.Mock, for: MyApp.Mailer)
# 4. config/test.exs
config :my_app, :mailer, MyApp.Mailer.Mock
# 5. Dispatcher (runtime lookup — important for library-style code)
defmodule MyApp.MailerDispatcher do
defp impl, do: Application.get_env(:my_app, :mailer, MyApp.Mailer.Swoosh)
def send_welcome(user), do: impl().send_welcome(user)
end
# 6. In the test
import Mox
setup :verify_on_exit! # ALWAYS — fails the test if expectations unmet
test "register sends welcome email" do
expect(MyApp.Mailer.Mock, :send_welcome, fn %User{email: "a@b.com"} -> :ok end)
assert {:ok, %User{}} = Accounts.register(%{email: "a@b.com", password: "pw"})
end
Async mode — decision table:
| Test scenario | Mox mode | async: |
|---|
| Single process uses the mock (most tests) | set_mox_private() (default) | true |
| Spawned process needs same expectations | set_mox_private() + allow/3 | true |
| Many processes (can't track them all) | set_mox_global() | false |
| Auto-pick based on test tag | set_mox_from_context() in setup | either |
4.5 Ecto Sandbox — database isolation
# test_helper.exs (typical Phoenix app already has this)
Ecto.Adapters.SQL.Sandbox.mode(MyApp.Repo, :manual)
# DataCase setup (per test)
setup tags do
pid = Ecto.Adapters.SQL.Sandbox.start_owner!(MyApp.Repo, shared: not tags[:async])
on_exit(fn -> Ecto.Adapters.SQL.Sandbox.stop_owner(pid) end)
:ok
end
# If a test spawns a process that needs DB access:
Ecto.Adapters.SQL.Sandbox.allow(MyApp.Repo, self(), spawned_pid)
# Sandbox modes:
# :manual (default in test_helper.exs) — must check out explicitly
# :auto — auto-checkout on first query (simple sync)
# {:shared, pid} — all processes share one connection (async: false only)
Rule: every Ecto-touching test module uses async: true unless it relies on global state. The sandbox guarantees per-test isolation.
4.6 Factories — ExMachina
Factories keep test data DRY, independent, and readable. Don't hand-build structs when you'll use the same shape in more than two tests.
Which factory function? — decision table:
| When you need... | Use this | Returns |
|---|
| A persisted row in the DB | insert(:user) | struct with :id set |
| A persisted row with overrides | insert(:user, email: "x@y.com") | struct |
| Many persisted rows | insert_list(5, :user) | list of structs |
| A struct in memory only (not persisted) | build(:user) | struct, no :id |
| Attrs map (atom keys) for changeset testing | params_for(:user) | %{email: "...", ...} |
| Attrs map (string keys) for controller testing | string_params_for(:user) | %{"email" => "...", ...} |
| Guaranteed unique field per call (avoid collisions) | sequence(:email, &"user-#{&1}@x.com") | string — used inside factory |
| Association in a factory | build(:user) | set the assoc to a built struct |
Canonical factory module:
defmodule MyApp.Factory do
use ExMachina.Ecto, repo: MyApp.Repo
def user_factory do
%User{
email: sequence(:email, &"user-#{&1}@example.com"),
name: "Test User",
password_hash: Bcrypt.hash_pwd_salt("secret-pw-123")
}
end
def admin_factory do
struct!(user_factory(), role: :admin)
end
def post_factory do
%Post{title: "Test Post", body: "Body", author: build(:user)}
end
end
4.7 Property testing with StreamData
use ExUnitProperties
property "Enum.reverse is its own inverse" do
check all list <- list_of(integer()), max_runs: 200 do
assert list == list |> Enum.reverse() |> Enum.reverse()
end
end
# Top generators: integer(), positive_integer(), float(), boolean(), atom(:alphanumeric),
# binary(), string(:ascii), string(:printable), list_of(gen), map_of(key, val), tuple({gen1, gen2}),
# one_of([gen1, gen2]), member_of([:a, :b, :c]), constant(value), term()
# Generator composition
map(gen, fn x -> transform(x) end) # Transform
filter(gen, fn x -> predicate(x) end) # Filter (use sparingly — can be slow)
bind(gen, fn x -> another_gen end) # Dependent generation
# Multi-value generation
gen all x <- integer(), y <- string(:ascii) do
{x, y}
end
When to reach for properties: anything with an obvious invariant (round-trips, sort/reverse, parser/serializer, merge operations, set operations, mathematical functions).
4.8 Controller and LiveView testing (essentials)
# --- Controller ---
test "GET /products returns 200", %{conn: conn} do
insert(:product, name: "Widget")
conn = get(conn, ~p"/products")
assert html_response(conn, 200) =~ "Widget"
end
test "POST /products with invalid returns 422", %{conn: conn} do
conn = post(conn, ~p"/products", product: %{name: ""})
assert json_response(conn, 422)["errors"]["name"] == ["can't be blank"]
end
# --- LiveView ---
import Phoenix.LiveViewTest
test "user can add a comment", %{conn: conn} do
post = insert(:post)
{:ok, view, _html} = live(conn, ~p"/posts/#{post}")
view
|> form("#comment-form", comment: %{body: "Nice!"})
|> render_submit()
assert has_element?(view, "[data-test=comment]", "Nice!")
end
# Full LiveView testing (render_async, hooks, file uploads, etc.) — see elixir-testing skill
4.9 Common commands
mix test
mix test test/path/file_test.exs
mix test test/path/file_test.exs:42
mix test --failed
mix test --stale
mix test --trace
mix test --max-failures 1
mix test --only slow
mix test --exclude integration
mix test --cover
mix test --seed 123
4.10 Diagnosing async test failures
| Symptom | Likely cause | Fix |
|---|
| Passes alone, fails with others | Shared global state (named GenServer, Application env) | Use async: false, OR isolate state per test |
| Random / intermittent failure | Race condition, timing | Replace Process.sleep with assert_receive pattern, timeout |
| DBConnection errors | Spawned process not allowed by sandbox | Ecto.Adapters.SQL.Sandbox.allow(Repo, self(), pid) |
| Time-dependent failure | Wall-clock assertion | Use assert_in_delta with tolerance, or inject a clock |
| Factory uniqueness collisions | Hard-coded values, no sequence/2 | Use sequence(:email, &"user-#{&1}@example.com") |
mix test fails on a fresh project with connection error | Default test alias runs ecto.create --quiet; no DB reachable | Bring the DB up before mix test (e.g. docker compose up -d postgres). The default alias is ["ecto.create --quiet", "ecto.migrate --quiet", "test"] — the error is from the alias, not your tests |
Registry post-shutdown cleanup race | Registry handles registered-pid :DOWN asynchronously; Registry.lookup/2 reads ETS directly so no GenServer.call can force a happens-before edge | Bounded poll — assert_receive cannot help here because Registry doesn't emit a completion message. See the template below. |
4.10.1 Registry-cleanup test pattern
Registry removes a registered pid's entries when it receives that pid's :DOWN. Both the :DOWN and the ETS cleanup are async with no signal exposed to test code. Registry.lookup/2 bypasses Registry's GenServer queue and reads ETS directly, so calling another Registry function won't force ordering either.
For a test that asserts "after the worker dies, Registry shows it gone", a bounded poll is the only correct shape:
test "registry forgets the worker after it dies" do
{:ok, pid} = MyApp.WorkerRegistry.start_worker(:alice)
Process.exit(pid, :kill)
refute_eventually(fn -> match?([_ | _], Registry.lookup(MyApp.Registry, :alice)) end)
end
# Test helper — bounded poll, plus a clear comment for any reviewer who
# reaches the Process.sleep and reaches for `assert_receive`.
defp refute_eventually(check, timeout_ms \\ 200, step_ms \\ 10) do
deadline = System.monotonic_time(:millisecond) + timeout_ms
poll = fn poll ->
cond do
not check.() -> :ok
System.monotonic_time(:millisecond) >= deadline -> flunk("condition never became false")
true -> Process.sleep(step_ms); poll.(poll)
end
end
poll.(poll)
end
This is the one well-justified Process.sleep in test code: Registry provides no signal, so no assert_receive will work.
5. Critical Patterns Claude Commonly Gets Wrong
These are the patterns that separate idiomatic Elixir from "Elixir-shaped imperative code." Each subsection gives the idiomatic template first, then variations, then a common-mistake BAD/GOOD.
5.1 Pipelines — the subject-first discipline
Idiomatic template:
# A pipeline is a sequence of transformations on a primary subject.
# The subject is always the first argument of each step.
raw_input
|> String.trim()
|> String.split("\n")
|> Enum.reject(&(&1 == ""))
|> Enum.map(&parse_line/1)
|> Enum.group_by(& &1.category)
|> Map.new(fn {k, v} -> {k, length(v)} end)
Rules of thumb:
- 2+ transformations → pipeline
- Exactly 1 → direct call
- The first value in the pipeline is the subject; every function after must take it as its first argument
- One pipe per line; never
a |> b() |> c() inlined
Variations:
# tap/1 — side effect without breaking the pipeline (returns input unchanged)
order
|> calculate_total()
|> tap(&Logger.debug("Total: #{&1}"))
|> apply_tax()
# then/2 — when the next step is not first-arg-compatible
cfg
|> Map.get(:timeout, 5_000)
|> then(&Process.send_after(self(), :check, &1))
# Conditional step — maybe_X/2 helper, keeps pipeline flat
data
|> transform()
|> maybe_validate(opts[:validate])
|> finalize()
defp maybe_validate(data, true), do: validate(data)
defp maybe_validate(data, _), do: data
# Piping into case — only at the END of a multi-step pipeline
conn
|> fetch_session("user_token")
|> case do
nil -> assign(conn, :current_user, nil)
token -> assign(conn, :current_user, Accounts.get_user_by_token(token))
end
BAD/GOOD:
# BAD — single-step pipe
name |> String.upcase()
# GOOD — direct call
String.upcase(name)
# BAD — piping into a lone reduce_while + case (single step)
Enum.reduce_while(items, {:ok, []}, fn item, {:ok, acc} ->
case validate(item) do
{:ok, v} -> {:cont, {:ok, [v | acc]}}
{:error, _} = e -> {:halt, e}
end
end)
|> case do
{:ok, acc} -> {:ok, Enum.reverse(acc)}
{:error, _} = error -> error
end
# GOOD — intermediate variable, then case
result =
Enum.reduce_while(items, {:ok, []}, fn item, {:ok, acc} ->
case validate(item) do
{:ok, v} -> {:cont, {:ok, [v | acc]}}
{:error, _} = e -> {:halt, e}
end
end)
case result do
{:ok, acc} -> {:ok, Enum.reverse(acc)}
{:error, _} = error -> error
end
# BAD — piping into an anonymous function (awkward)
data |> (fn x -> x * 2 end).()
# GOOD — use then/1
data |> then(&(&1 * 2))
# BAD — multiple pipes on one line
list |> Enum.map(&process/1) |> Enum.sum()
# GOOD — one pipe per line
list
|> Enum.map(&process/1)
|> Enum.sum()
5.2 Pattern matching in function heads
Idiomatic template:
# Multi-clause dispatch on data shape — most powerful Elixir feature.
# Each clause handles a specific case. The compiler warns on unmatched cases.
def handle_event(%Click{x: x, y: y}), do: on_click(x, y)
def handle_event(%Submit{form: form}), do: on_submit(form)
def handle_event(%KeyDown{key: "Escape"}), do: cancel()
def handle_event(%KeyDown{key: key}), do: on_key(key)
def handle_event(unknown), do: {:error, {:unknown_event, unknown}}
# Guards refine the match
def process(n) when is_integer(n) and n > 0, do: :positive
def process(n) when is_integer(n) and n < 0, do: :negative
def process(0), do: :zero
def process(n) when is_float(n), do: :float
def process(_), do: :not_a_number
Canonical shapes:
# Tagged tuples — result dispatch
def handle({:ok, value}), do: process(value)
def handle({:error, reason}), do: log_error(reason)
# Nested destructure — pull deep fields in the head
def city(%User{address: %Address{city: city}}), do: {:ok, city}
def city(_), do: {:error, :no_city}
# Pin to match against an existing variable (NOT bind)
expected_id = 42
case event do
%{user_id: ^expected_id} -> :match
_ -> :no_match
end
# Keep the whole struct bound while also destructuring fields
def greet(%User{name: name} = user), do: "Hello #{name}, id=#{user.id}"
BAD/GOOD:
# BAD — if/else dispatching on shape
def handle(msg) do
if is_map(msg) and Map.has_key?(msg, :type) do
if msg.type == :error, do: handle_error(msg), else: handle_ok(msg)
end
end
# GOOD — multi-clause with pattern
def handle(%{type: :error} = msg), do: handle_error(msg)
def handle(%{type: _} = msg), do: handle_ok(msg)
# BAD — forgetting the pin, variable rebinds and matches ANYTHING
target = 42
case x do
target -> :match # Always matches; `target` rebinds to x
end
# GOOD — pin operator
target = 42
case x do
^target -> :match
_ -> :no_match
end
# BAD — %{} matches ANY map, not just empty
def classify(%{}), do: :empty
# GOOD — guard for empty map
def classify(map) when map_size(map) == 0, do: :empty
def classify(_), do: :non_empty
5.3 with — chaining ok/error operations
Idiomatic template:
def create_order(user_id, product_id, qty) do
with {:ok, user} <- Users.get(user_id),
{:ok, product} <- Products.get(product_id),
:ok <- validate_stock(product, qty),
{:ok, order} <- insert_order(user, product, qty) do
{:ok, order}
end
end
When to use else:
Only when you need to transform the error on the way out. Otherwise omit the else — the first non-matching value is returned as-is.
def create_order(user_id, product_id, qty) do
with {:ok, user} <- Users.get(user_id),
{:ok, product} <- Products.get(product_id),
:ok <- validate_stock(product, qty),
{:ok, order} <- insert_order(user, product, qty) do
{:ok, order}
else
{:error, :not_found} -> {:error, :resource_not_found}
{:error, :insufficient_stock} -> {:error, :out_of_stock}
# Any other {:error, _} falls through unchanged
end
end
Tagged-tuple with — label each clause for precise error handling:
# Use when several steps can return the same error shape and you need
# to distinguish which step failed.
with {:user, {:ok, user}} <- {:user, fetch_user(id)},
{:auth, :ok} <- {:auth, authorize(user, action)},
{:save, {:ok, result}} <- {:save, save(user)} do
{:ok, result}
else
{:user, {:error, _}} -> {:error, :user_not_found}
{:auth, {:error, _}} -> {:error, :unauthorized}
{:save, {:error, changeset}} -> {:error, changeset}
end
BAD/GOOD:
# BAD — nested case
def register(params) do
case validate_email(params) do
{:ok, email} ->
case validate_password(params) do
{:ok, password} ->
case create_user(email, password) do
{:ok, user} -> {:ok, user}
{:error, reason} -> {:error, reason}
end
{:error, reason} -> {:error, reason}
end
{:error, reason} -> {:error, reason}
end
end
# GOOD — with chain
def register(params) do
with {:ok, email} <- validate_email(params),
{:ok, password} <- validate_password(params),
{:ok, user} <- create_user(email, password) do
{:ok, user}
end
end
# BAD — with for a single op (overkill, harder to read)
with {:ok, user} <- Accounts.fetch(id) do
process(user)
end
# GOOD — case for a single op
case Accounts.fetch(id) do
{:ok, user} -> process(user)
{:error, _} = e -> e
end
5.4 Comprehensions — for when it wins over pipelines
Use for when you're doing one or more of:
- Pattern-matching generators (silent skip on non-match)
- Collecting into a specific type (
into:)
- Accumulator with tuple/map state (
reduce:)
- Multiple generators (Cartesian / nested iteration)
- Binary iteration (
<<byte <- data>>)
- Deduplication (
uniq: true)
# Pattern in generator — skip non-successful results silently
for {:ok, value} <- results, do: value
# into: MapSet — build a set in one pass
for app <- apps, module <- Application.spec(app, :modules), into: MapSet.new(), do: module
# Binary comprehension — iterate bytes
for <<byte <- string>>, byte not in ?\s..?~, into: "", do: <<byte>>
# reduce: — tuple accumulator in one pass
for {name, field} <- fields, reduce: {[], []} do
{keep, drop} ->
case field.writable do
:always -> {[name | keep], drop}
_ -> {keep, [name | drop]}
end
end
# Multiple generators — cross product
for x <- 1..3, y <- 1..3, x <= y, do: {x, y}
#=> [{1,1}, {1,2}, {1,3}, {2,2}, {2,3}, {3,3}]
# uniq: true — inline deduplication
for type_expr <- args, var <- collect_vars(type_expr), uniq: true, do: var
5.5 Recursion — the third iteration tool
Depth: idioms-reference.md §Recursion — Last Call Optimization (LCO) explained with tail-position precision table, body-vs-tail trade-offs with modern BEAM/JIT performance nuance, accumulator-reverse pattern, binary-pattern recursion, tree traversal, mutual recursion, recursion-vs-reduce_while decision, wrapping recursive walkers as lazy streams.
Recursion is a first-class iteration tool in Elixir, not a fallback. A tail-recursive function with pattern matching is the functional equivalent of an imperative while loop — constant stack, pattern-dispatch on the state.
When recursion is the right answer:
- Long-running loops — GenServer message loops, TCP accept loops, retry loops. Elixir's idiomatic
while (true).
- Early termination with halt conditions spanning multiple accumulators (simple cases fit
Enum.reduce_while).
- Tree / graph / AST traversal where the structure is genuinely recursive.
- Binary decoders —
<<byte, rest::binary>> = data; decode(rest) is the dominant BEAM-optimized pattern for parsers.
- Parsers and walkers where each element shapes what you do with the next.
- Infinite / lazy generation — wrapped in
Stream.iterate/Stream.unfold/Stream.resource.
- Custom enumeration — implementing
Enumerable.
Tail vs body recursion — both are first-class. The Erlang Efficiency Guide (Seven Myths of Erlang Performance) explicitly says: "Use the version that makes your code cleaner (hint: it is usually the body-recursive version)." Since R12B, body-recursive list construction uses the same memory as tail + reverse. The stdlib's :lists.map/2, :lists.filter/2, and list comprehensions are all body-recursive by choice.
When each is right:
| Situation | Prefer |
|---|
| Unbounded / adversarial input (user lists, streams) | Tail — guaranteed constant stack |
| Long-running process loop | Tail — MUST (never terminates) |
| Known-bounded structure (tree, AST, expression grammar, recurrence) | Body — clearer, often the better choice |
| List transformation where order matters | Either — body-recursive is often cleaner; tail + reverse is explicit |
| Modern OTP (24+) with JIT, performance matters | Benchmark — JIT has reversed some pre-JIT rules of thumb |
The while-loop analogy:
# Imperative: while (running) { msg = receive(); handle(msg); }
def loop(state) do
receive do
:stop -> :ok
msg -> msg |> handle(state) |> loop() # tail call — constant stack
end
end
# Imperative: while (!done) { if (try_work()) break; sleep(); }
def retry(attempt \\ 1) do
case work() do
{:ok, r} -> {:ok, r}
{:error, _} when attempt >= @max -> {:error, :exhausted}
{:error, _} -> Process.sleep(backoff(attempt)); retry(attempt + 1)
end
end
Tail-position gotchas (where LCO silently DOESN'T apply — see idioms-reference for full list):
with ... else ... — the else clause keeps the result for re-matching; final call is NOT tail.
try do ... end — the protected do body is NOT tail position (stacktrace is kept).
- Arithmetic / construction around the call:
[x | recur(t)] is body-recursive (fine for bounded input; not "broken").
BAD/GOOD:
# Body-recursive — fine for reasonable inputs; stdlib :lists.map works exactly this way
def double_all([]), do: []
def double_all([h | t]), do: [h * 2 | double_all(t)]
# Tail-recursive + reverse — use when input may be unbounded
def double_all(list), do: do_double_all(list, [])
defp do_double_all([], acc), do: Enum.reverse(acc)
defp do_double_all([h | t], acc), do: do_double_all(t, [h * 2 | acc])
# Usually clearest — let Enum handle bounded-list work
def double_all(list), do: Enum.map(list, &(&1 * 2))
Real anti-patterns (these ARE broken):
# BAD — O(n²) from append in accumulator
defp build([], acc), do: acc
defp build([h | t], acc), do: build(t, acc ++ [process(h)]) # ++ on left operand!
# BAD — reimplementing Enum.map
def each_squared(list), do: do_each_squared(list, [])
defp do_each_squared([], acc), do: Enum.reverse(acc)
defp do_each_squared([h | t], acc), do: do_each_squared(t, [h * h | acc])
# → just write: Enum.map(list, &(&1 * &1))
5.6 Guards — constraints at the function boundary