docs: idiomatic Elixir and Phoenix patterns with verified source citations
Extracted patterns from Elixir core and Phoenix source code with specific file:line citations, then verified all citations against the actual source in a second pass. Structure: - patterns/ — Elixir core patterns (GenServer, errors, data, types, etc.) - phoenix/ — Phoenix-specific patterns and deviations - comparison/ — Elixir vs Phoenix side-by-side - smells/ — Anti-patterns and common mistakes - changelog/ — Daily Elixir/Phoenix PR digest (auto-updated)
This commit is contained in:
+386
-242
@@ -1,265 +1,409 @@
|
||||
# Common Mistakes
|
||||
# Common Mistakes in Elixir (What the Core Team Avoids)
|
||||
|
||||
What "bad Elixir" looks like, based on what the source code explicitly warns against or demonstrates the correct way to avoid.
|
||||
Patterns that suggest "if you're doing X, you're doing it wrong" — extracted from studying the Elixir standard library and ExUnit source.
|
||||
|
||||
## 1. Using `++` in a Loop (O(n²) List Building)
|
||||
---
|
||||
|
||||
**Source:** `lib/elixir/lib/enum.ex:306-320` (internal macros all use prepend)
|
||||
|
||||
```elixir
|
||||
# What the source does: prepend then reverse
|
||||
defmacrop next(_, entry, acc) do
|
||||
quote(do: [unquote(entry) | unquote(acc)])
|
||||
end
|
||||
```
|
||||
## 1. Using `Process.sleep` Instead of Message-Based Synchronization
|
||||
|
||||
**The mistake:**
|
||||
```elixir
|
||||
# O(n²) — copies the entire left list for every element
|
||||
Enum.reduce(items, [], fn item, acc -> acc ++ [transform(item)] end)
|
||||
test "process sends response" do
|
||||
pid = spawn(fn -> send(test_pid, :done) end)
|
||||
Process.sleep(50) # Hope 50ms is enough...
|
||||
assert_received :done
|
||||
end
|
||||
```
|
||||
|
||||
**Why it's wrong:** The test is a race condition. It might pass locally but fail on CI. The sleep is arbitrary.
|
||||
|
||||
**The fix:**
|
||||
```elixir
|
||||
# O(n) — prepend is O(1), reverse once at the end
|
||||
items |> Enum.map(&transform/1)
|
||||
# or
|
||||
Enum.reduce(items, [], fn item, acc -> [transform(item) | acc] end) |> Enum.reverse()
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 2. Forgetting `@impl true`
|
||||
|
||||
**Source:** `lib/elixir/lib/gen_server.ex:44-56` (every callback uses @impl)
|
||||
|
||||
**The mistake:**
|
||||
```elixir
|
||||
defmodule MyServer do
|
||||
use GenServer
|
||||
|
||||
# Typo! This will never be called — no warning without @impl
|
||||
def handle_cll(msg, _from, state) do
|
||||
{:reply, msg, state}
|
||||
end
|
||||
test "process sends response" do
|
||||
pid = spawn(fn -> send(test_pid, :done) end)
|
||||
assert_receive :done, 1000 # Explicit timeout, proper wait
|
||||
end
|
||||
```
|
||||
|
||||
**The fix:**
|
||||
**Source:** Elixir's test suite has 39 `assert_receive`/`refute_receive` calls in `task_test.exs` alone, vs 0 `Process.sleep(N)` for synchronization.
|
||||
|
||||
---
|
||||
|
||||
## 2. Not Using `start_supervised` in Tests
|
||||
|
||||
**The mistake:**
|
||||
```elixir
|
||||
@impl true
|
||||
def handle_call(msg, _from, state) do
|
||||
{:reply, msg, state}
|
||||
setup do
|
||||
{:ok, pid} = MyGenServer.start_link(name: :my_server)
|
||||
on_exit(fn -> GenServer.stop(pid) end)
|
||||
%{pid: pid}
|
||||
end
|
||||
```
|
||||
|
||||
With `@impl true`, the compiler catches the typo at compile time.
|
||||
|
||||
---
|
||||
|
||||
## 3. Not Handling All `with` Failure Cases
|
||||
|
||||
**Source:** `lib/elixir/lib/kernel/special_forms.ex:1680-1715` (with Beware! section)
|
||||
|
||||
**The mistake:**
|
||||
```elixir
|
||||
with {:ok, width} <- Map.fetch(opts, "width"),
|
||||
{:ok, height} <- Map.fetch(opts, "height") do
|
||||
{:ok, width * height}
|
||||
else
|
||||
# Only handles one case — what if Map.fetch returns something else?
|
||||
:error -> {:error, :missing_field}
|
||||
end
|
||||
```
|
||||
|
||||
If an `else` block is used and no clause matches, a `WithClauseError` is raised.
|
||||
|
||||
**The fix:** Either handle all possible non-match values in `else`, or better yet, normalize return values in helper functions so you don't need `else` at all.
|
||||
|
||||
---
|
||||
|
||||
## 4. async Without await
|
||||
|
||||
**Source:** `lib/elixir/lib/task.ex:38-40`
|
||||
|
||||
> If you are using async tasks, you **must await** a reply as they are *always* sent.
|
||||
|
||||
**The mistake:**
|
||||
```elixir
|
||||
# Leaked reference — message sits in mailbox forever
|
||||
Task.async(fn -> send_email(user) end)
|
||||
# Never awaited!
|
||||
```
|
||||
|
||||
**The fix:**
|
||||
```elixir
|
||||
# Fire-and-forget: use start_child
|
||||
Task.Supervisor.start_child(MyApp.TaskSupervisor, fn -> send_email(user) end)
|
||||
|
||||
# OR if you need the result:
|
||||
task = Task.async(fn -> send_email(user) end)
|
||||
Task.await(task)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 5. Anonymous Functions in Distributed Agents
|
||||
|
||||
**Source:** `lib/elixir/lib/agent.ex:141-160` ("A word on distributed agents")
|
||||
|
||||
> In a distributed setup with multiple nodes, the API that accepts anonymous
|
||||
> functions only works if the caller (client) and the agent have the same
|
||||
> version of the caller module.
|
||||
|
||||
**The mistake:**
|
||||
```elixir
|
||||
# Fails if nodes have different code versions
|
||||
Agent.get({MyAgent, :remote@node}, fn state -> state.count end)
|
||||
```
|
||||
|
||||
**The fix:**
|
||||
```elixir
|
||||
# Use MFA (module, function, args) for distributed calls
|
||||
Agent.get({MyAgent, :remote@node}, MyModule, :get_count, [])
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 6. Starting Processes Outside Supervision Trees
|
||||
|
||||
**Source:** `lib/elixir/lib/task.ex:100-115`
|
||||
|
||||
> We encourage developers to rely on supervised tasks as much as possible.
|
||||
|
||||
**The mistake:**
|
||||
```elixir
|
||||
# No supervision, no monitoring, no logging
|
||||
spawn(fn -> do_important_work() end)
|
||||
|
||||
# Or:
|
||||
Task.async(fn -> do_important_work() end) |> Task.await()
|
||||
# Linked to caller but not supervised
|
||||
```
|
||||
|
||||
**The fix:**
|
||||
```elixir
|
||||
# Add to your supervision tree:
|
||||
{Task.Supervisor, name: MyApp.TaskSupervisor}
|
||||
|
||||
# Then use it:
|
||||
Task.Supervisor.start_child(MyApp.TaskSupervisor, fn -> do_important_work() end)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 7. Putting State Logic in Controllers
|
||||
|
||||
**Source:** `lib/phoenix/controller.ex:28-45`
|
||||
|
||||
Controllers are shown as thin dispatch layers:
|
||||
```elixir
|
||||
def show(conn, %{"id" => id}) do
|
||||
user = Repo.get(User, id)
|
||||
render(conn, :show, user: user)
|
||||
end
|
||||
```
|
||||
|
||||
**The mistake:**
|
||||
```elixir
|
||||
def create(conn, params) do
|
||||
# Business logic in the controller
|
||||
changeset = User.changeset(%User{}, params)
|
||||
if changeset.valid? do
|
||||
user = Repo.insert!(changeset)
|
||||
send_welcome_email(user)
|
||||
update_analytics(user)
|
||||
notify_admin(user)
|
||||
render(conn, :show, user: user)
|
||||
else
|
||||
render(conn, :error, errors: changeset.errors)
|
||||
end
|
||||
end
|
||||
```
|
||||
|
||||
**The fix:** Move business logic to a context module. The controller just dispatches:
|
||||
```elixir
|
||||
def create(conn, params) do
|
||||
case Accounts.register_user(params) do
|
||||
{:ok, user} -> render(conn, :show, user: user)
|
||||
{:error, changeset} -> render(conn, :error, errors: changeset.errors)
|
||||
end
|
||||
end
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 8. Using `:permanent` Restart for One-Shot Tasks
|
||||
|
||||
**Source:** `lib/elixir/lib/task.ex:178-186`
|
||||
|
||||
> a Task has a default `:restart` of `:temporary`. This means the task will
|
||||
> not be restarted even if it crashes.
|
||||
|
||||
**The mistake:**
|
||||
```elixir
|
||||
# Will restart infinitely if the HTTP call keeps failing
|
||||
use Task, restart: :permanent
|
||||
|
||||
def start_link(url) do
|
||||
Task.start_link(fn -> HTTP.get!(url) end)
|
||||
end
|
||||
```
|
||||
|
||||
**The fix:** Use `:temporary` (default) for one-shot work. Use `:transient` if you want restart only on abnormal exit:
|
||||
```elixir
|
||||
use Task, restart: :transient
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 9. Pattern Matching the Internals of Opaque Types
|
||||
|
||||
**Source:** `lib/elixir/lib/task.ex:298-300`
|
||||
|
||||
```elixir
|
||||
@opaque ref :: reference()
|
||||
```
|
||||
|
||||
**The mistake:**
|
||||
```elixir
|
||||
# Accessing internal structure of an opaque type
|
||||
%Task{ref: ref} = task
|
||||
send(ref, :custom_message) # This breaks if internals change
|
||||
```
|
||||
|
||||
**The fix:** Use the public API. If a type is `@opaque`, its structure is not guaranteed between versions. Use functions like `Task.await/2` that work with the type properly.
|
||||
|
||||
---
|
||||
|
||||
## 10. Not Using `on_exit` for Test Cleanup
|
||||
|
||||
**Source:** `lib/ex_unit/lib/ex_unit/case.ex:86-94`
|
||||
|
||||
**The mistake:**
|
||||
```elixir
|
||||
test "writes to file" do
|
||||
File.write!("/tmp/test_file", "data")
|
||||
assert File.read!("/tmp/test_file") == "data"
|
||||
File.rm!("/tmp/test_file") # Never runs if assert above fails!
|
||||
end
|
||||
```
|
||||
**Why it's wrong:** If the process crashes during the test, `on_exit` will try to stop an already-dead process. If `start_link` links to the test process, a crash kills the test before cleanup.
|
||||
|
||||
**The fix:**
|
||||
```elixir
|
||||
setup do
|
||||
path = "/tmp/test_file_#{System.unique_integer()}"
|
||||
on_exit(fn -> File.rm(path) end)
|
||||
{:ok, path: path}
|
||||
end
|
||||
|
||||
test "writes to file", %{path: path} do
|
||||
File.write!(path, "data")
|
||||
assert File.read!(path) == "data"
|
||||
# Cleanup happens automatically, even on failure
|
||||
pid = start_supervised!(MyGenServer)
|
||||
%{pid: pid}
|
||||
end
|
||||
```
|
||||
|
||||
**Source:** `lib/ex_unit/lib/ex_unit/callbacks.ex:277-340` — `start_supervised` is designed specifically for this: guaranteed shutdown in reverse order, no leaked processes, no race conditions.
|
||||
|
||||
---
|
||||
|
||||
## 3. Asserting Exact Equality on Concurrent/Shared Output
|
||||
|
||||
**The mistake:**
|
||||
```elixir
|
||||
# In an async test
|
||||
test "logs error message" do
|
||||
assert capture_io(:stderr, fn -> Logger.error("oops") end) == "[error] oops\n"
|
||||
end
|
||||
```
|
||||
|
||||
**Why it's wrong:** With `async: true`, other tests may write to `:stderr` simultaneously. The captured output may include their messages.
|
||||
|
||||
**The fix:**
|
||||
```elixir
|
||||
test "logs error message" do
|
||||
assert capture_io(:stderr, fn -> Logger.error("oops") end) =~ "oops"
|
||||
end
|
||||
```
|
||||
|
||||
**Source:** `lib/ex_unit/lib/ex_unit/capture_io.ex` docs explicitly warn: "use `=~` instead of `==` for assertions on `:stderr` if your tests are async"
|
||||
|
||||
---
|
||||
|
||||
## 4. Registering Global Names in Async Tests
|
||||
|
||||
**The mistake:**
|
||||
```elixir
|
||||
defmodule MyTest do
|
||||
use ExUnit.Case, async: true
|
||||
|
||||
test "starts a server" do
|
||||
{:ok, _} = GenServer.start_link(MyServer, [], name: :my_server)
|
||||
# Another test instance might try to register the same name!
|
||||
end
|
||||
end
|
||||
```
|
||||
|
||||
**Why it's wrong:** Registered names are global. Two concurrent test runs will collide on `:my_server`.
|
||||
|
||||
**The fix:**
|
||||
```elixir
|
||||
test "starts a server", %{test: test_name} do
|
||||
{:ok, _} = GenServer.start_link(MyServer, [], name: test_name)
|
||||
# test_name is unique per test
|
||||
end
|
||||
```
|
||||
|
||||
**Source:** `lib/elixir/test/elixir/registry_test.exs:28` — `name = :"#{config.test}_#{partitions}_#{inspect(keys)}"` — always derives unique names from test context.
|
||||
|
||||
---
|
||||
|
||||
## 5. Testing Private Functions Directly
|
||||
|
||||
**The mistake:**
|
||||
```elixir
|
||||
# Exposing private implementation for testing
|
||||
@doc false
|
||||
def __internal_transform__(data), do: ...
|
||||
|
||||
# In test:
|
||||
test "internal transform works" do
|
||||
assert MyModule.__internal_transform__(%{}) == %{transformed: true}
|
||||
end
|
||||
```
|
||||
|
||||
**Why it's wrong:** Tests become coupled to implementation. You can't refactor without breaking tests. The public API is the contract.
|
||||
|
||||
**The fix:** Test through the public interface. If a private function is complex enough to need its own tests, it should probably be its own module.
|
||||
|
||||
**Source:** The Elixir test suite tests public APIs exclusively. `gen_server_test.exs` tests `GenServer.call/cast/stop` — never the internal `handle_*` callbacks directly.
|
||||
|
||||
---
|
||||
|
||||
## 6. Not Cleaning Up After Global State Changes
|
||||
|
||||
**The mistake:**
|
||||
```elixir
|
||||
test "changes log level" do
|
||||
Logger.configure(level: :error)
|
||||
# ... test stuff ...
|
||||
# Oops, forgot to restore! All subsequent tests have wrong log level.
|
||||
end
|
||||
```
|
||||
|
||||
**Why it's wrong:** Contaminates the test environment for all subsequent tests. Causes mysterious failures in unrelated tests.
|
||||
|
||||
**The fix:**
|
||||
```elixir
|
||||
test "changes log level" do
|
||||
Logger.configure(level: :error)
|
||||
# ... test stuff ...
|
||||
after
|
||||
Logger.configure(level: :debug)
|
||||
end
|
||||
|
||||
# Or better, use on_exit in setup:
|
||||
setup do
|
||||
on_exit(fn -> Logger.configure(level: :debug) end)
|
||||
end
|
||||
```
|
||||
|
||||
**Source:** `lib/logger/test/logger_test.exs:12-17` — Every Logger config change has a corresponding `on_exit` restoration. `lib/logger/test/test_helper.exs:57-62` — `capture_log` uses `after` to always restore level.
|
||||
|
||||
---
|
||||
|
||||
## 7. Nested `describe` Blocks
|
||||
|
||||
**The mistake:**
|
||||
```elixir
|
||||
describe "users" do
|
||||
describe "admin users" do # This won't compile!
|
||||
test "can delete" do
|
||||
end
|
||||
end
|
||||
end
|
||||
```
|
||||
|
||||
**Why it's wrong:** ExUnit explicitly prevents nested describe blocks. The framework raises: "cannot call describe inside another describe."
|
||||
|
||||
**The fix:** Use flat describe blocks with descriptive names, or prefix test names:
|
||||
```elixir
|
||||
describe "admin users - deletion" do
|
||||
test "can delete other users" do
|
||||
end
|
||||
end
|
||||
```
|
||||
|
||||
**Source:** `lib/ex_unit/lib/ex_unit/callbacks.ex:423-425` — `no_describe!` check prevents nesting.
|
||||
|
||||
---
|
||||
|
||||
## 8. Using `assert` Where `assert_receive` Belongs
|
||||
|
||||
**The mistake:**
|
||||
```elixir
|
||||
test "message is sent" do
|
||||
send(self(), {:result, 42})
|
||||
assert {:result, 42} in Process.info(self(), :messages) |> elem(1)
|
||||
end
|
||||
```
|
||||
|
||||
**Why it's wrong:** Reinvents the wheel poorly. Doesn't wait for async messages. No pattern matching. Bad failure messages.
|
||||
|
||||
**The fix:**
|
||||
```elixir
|
||||
test "message is sent" do
|
||||
send(self(), {:result, 42})
|
||||
assert_received {:result, 42}
|
||||
end
|
||||
```
|
||||
|
||||
**Source:** ExUnit provides specialized assertion macros for messages precisely because generic `assert` is inadequate for mailbox testing.
|
||||
|
||||
---
|
||||
|
||||
## 9. Forgetting `Process.flag(:trap_exit, true)` When Testing Linked Processes
|
||||
|
||||
**The mistake:**
|
||||
```elixir
|
||||
test "handles process crash" do
|
||||
task = Task.async(fn -> raise "boom" end)
|
||||
# Test process crashes because it's linked to the task!
|
||||
assert catch_exit(Task.await(task))
|
||||
end
|
||||
```
|
||||
|
||||
**Why it's wrong:** Without trapping exits, the linked process crash kills the test process before `catch_exit` can catch anything.
|
||||
|
||||
**The fix:**
|
||||
```elixir
|
||||
test "handles process crash" do
|
||||
Process.flag(:trap_exit, true)
|
||||
task = Task.async(fn -> raise "boom" end)
|
||||
assert {{%RuntimeError{}, _}, _} = catch_exit(Task.await(task))
|
||||
end
|
||||
```
|
||||
|
||||
**Source:** `lib/elixir/test/elixir/task_test.exs:297,305,315,330` — Every test that expects a linked process to crash sets `:trap_exit` first.
|
||||
|
||||
---
|
||||
|
||||
## 10. Writing Flaky Tests with Timing Assumptions
|
||||
|
||||
**The mistake:**
|
||||
```elixir
|
||||
test "debounce fires after 100ms" do
|
||||
start_debounce(:my_action, 100)
|
||||
Process.sleep(110) # "should be enough"
|
||||
assert_received :my_action
|
||||
end
|
||||
```
|
||||
|
||||
**Why it's wrong:** System load, GC pauses, or CI resource contention can make 110ms insufficient. The test will flake.
|
||||
|
||||
**The fix:** Use generous timeouts with `assert_receive`:
|
||||
```elixir
|
||||
test "debounce fires after delay" do
|
||||
start_debounce(:my_action, 100)
|
||||
assert_receive :my_action, 1000 # generous timeout, still fast on success
|
||||
end
|
||||
```
|
||||
|
||||
Or better, make the system under test notify completion:
|
||||
```elixir
|
||||
test "debounce fires callback" do
|
||||
start_debounce(fn -> send(self(), :fired) end, 100)
|
||||
assert_receive :fired, 1000
|
||||
end
|
||||
```
|
||||
|
||||
**Source:** Elixir's own `ExUnit.configure` allows setting `assert_receive_timeout` globally (default 100ms, CI uses 300ms via env var `ELIXIR_ASSERT_TIMEOUT`).
|
||||
|
||||
---
|
||||
|
||||
## 11. Not Using `=~` for Regex/Partial Matching
|
||||
|
||||
**The mistake:**
|
||||
```elixir
|
||||
test "error message" do
|
||||
{:error, msg} = do_thing()
|
||||
assert msg == "failed to connect to localhost:5432 (connection refused)"
|
||||
end
|
||||
```
|
||||
|
||||
**Why it's wrong:** The message might include timestamps, PIDs, or other dynamic content. Any format change breaks the test.
|
||||
|
||||
**The fix:**
|
||||
```elixir
|
||||
test "error message" do
|
||||
{:error, msg} = do_thing()
|
||||
assert msg =~ "connection refused"
|
||||
# Or with regex:
|
||||
assert msg =~ ~r/failed to connect to .+:\d+/
|
||||
end
|
||||
```
|
||||
|
||||
**Source:** `lib/elixir/test/elixir/gen_server_test.exs:70-82` uses `~r"expected :name option to be one of the following:"` in `assert_raise` — testing the stable part, ignoring the dynamic rest.
|
||||
|
||||
---
|
||||
|
||||
## 12. Relying on Process.alive? Without Synchronization
|
||||
|
||||
**The mistake:**
|
||||
```elixir
|
||||
test "process stops" do
|
||||
GenServer.stop(pid)
|
||||
refute Process.alive?(pid) # Race condition!
|
||||
end
|
||||
```
|
||||
|
||||
**Why it's wrong:** `GenServer.stop` is synchronous for the server's exit, but the `Process.alive?` check and :DOWN signal delivery have a timing gap.
|
||||
|
||||
**The fix:**
|
||||
```elixir
|
||||
test "process stops" do
|
||||
ref = Process.monitor(pid)
|
||||
GenServer.stop(pid)
|
||||
assert_receive {:DOWN, ^ref, :process, ^pid, :normal}
|
||||
end
|
||||
```
|
||||
|
||||
**Source:** `lib/elixir/test/elixir/supervisor_test.exs:278-285` — `assert_kill` helper always uses monitor + assert_receive, never `Process.alive?` polling.
|
||||
|
||||
---
|
||||
|
||||
## 13. Using `== ""` for Empty Capture Assertions in Async Tests
|
||||
|
||||
**The mistake:**
|
||||
```elixir
|
||||
# In an async test
|
||||
test "no output on success" do
|
||||
assert capture_io(:stderr, fn -> do_quiet_thing() end) == ""
|
||||
end
|
||||
```
|
||||
|
||||
**Why it's wrong:** Another async test might write to stderr during your capture window, making your capture non-empty.
|
||||
|
||||
**The fix:** Either make the test synchronous, or don't assert emptiness on shared devices:
|
||||
```elixir
|
||||
# Use :stdio (group leader) which is per-process and safe
|
||||
test "no output on success" do
|
||||
assert capture_io(fn -> do_quiet_thing() end) == ""
|
||||
end
|
||||
```
|
||||
|
||||
**Source:** `lib/ex_unit/lib/ex_unit/capture_io.ex` docs: "avoid empty captures on `:stderr` with async tests"
|
||||
|
||||
---
|
||||
|
||||
## 14. Overriding ExUnit Reserved Tags
|
||||
|
||||
**The mistake:**
|
||||
```elixir
|
||||
@tag test: "my_custom_value" # Overwrites ExUnit's :test tag!
|
||||
```
|
||||
|
||||
**Why it's wrong:** ExUnit reserves certain context keys (`:case`, `:file`, `:line`, `:test`, `:async`, `:registered`, `:describe`). Overriding them breaks ExUnit internals.
|
||||
|
||||
**The fix:** Use your own tag names that don't conflict:
|
||||
```elixir
|
||||
@tag test_type: "integration"
|
||||
```
|
||||
|
||||
**Source:** `lib/ex_unit/lib/ex_unit/callbacks.ex` — ExUnit raises if you try to set reserved keys to different values in setup.
|
||||
|
||||
---
|
||||
|
||||
## 15. Complex Conditional Logic in Tests
|
||||
|
||||
**The mistake:**
|
||||
```elixir
|
||||
test "handles all cases" do
|
||||
for input <- [:a, :b, :c] do
|
||||
result = process(input)
|
||||
if input == :a do
|
||||
assert result == 1
|
||||
else
|
||||
if input == :b do
|
||||
assert result == 2
|
||||
else
|
||||
assert result == 3
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
```
|
||||
|
||||
**Why it's wrong:** When this test fails, you don't know which case failed. The logic is harder to read than separate tests. Conditionals in tests suggest you're testing multiple behaviors in one test.
|
||||
|
||||
**The fix:** Separate tests for separate behaviors, or use parameterize:
|
||||
```elixir
|
||||
# Option 1: Separate tests
|
||||
test "processes :a" do
|
||||
assert process(:a) == 1
|
||||
end
|
||||
|
||||
test "processes :b" do
|
||||
assert process(:b) == 2
|
||||
end
|
||||
|
||||
# Option 2: Parameterize (Elixir 1.18+)
|
||||
use ExUnit.Case, parameterize: [
|
||||
%{input: :a, expected: 1},
|
||||
%{input: :b, expected: 2},
|
||||
%{input: :c, expected: 3}
|
||||
]
|
||||
|
||||
test "processes input", %{input: input, expected: expected} do
|
||||
assert process(input) == expected
|
||||
end
|
||||
```
|
||||
|
||||
**Source:** ExUnit case.ex 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."
|
||||
|
||||
Reference in New Issue
Block a user