Testing Essentials
Canonical FP bar: docs/fcis-engineering-rules.md — Functional Core, Imperative Shell: pure domain modules; side effects at edges. Prefer testing pure core without Repo; use DataCase only for true persistence boundaries.
RULES — Follow these with no exceptions
1. Follow the project's existing test setup patterns — don't inline DataCase/ConnCase boilerplate that the project already abstracts away
2. Use async: true only when safe — avoid for DB contexts with shared rows, LiveView, Application.put_env, and external services
3. Define test data in fixtures (test/support/) — never build it inline across multiple tests
4. Use has_element?/2 and element/2 for LiveView assertions — not html =~ "text" for structure checks
5. Always test the unauthorized case for any protected resource
6. Never hardcode dates — use relative timestamps to prevent flaky tests
FCIS at this boundary
Prefer unit-testing pure core without the database. Use DataCase for true persistence; use plain ExUnit for pure modules.
❌ Bad: every pure calculation goes through Repo
test "discount" do
order = insert(:order)
assert Orders.apply_discount(order.id, 10).total == 90
end
✅ Good: pure core test is side-effect free
test "discount" do
assert Pricing.with_discount(100, 10) == 90
end
Workflow: Writing a New Test File
Follow these steps in order, with explicit validation at each checkpoint:
- Check existing fixtures — inspect
test/support/fixtures/for relevant fixtures before creating new ones - Create fixture if needed — add to the appropriate fixtures module (see Fixture Pattern below)
- Verify compilation — run
mix testto confirm the fixture compiles before writing any tests - Write the failing test — implement the test case; run
mix test path/to/file_test.exsand confirm it fails with a meaningful message (not a compile error) - Verify the failure message — the failure should describe a missing behaviour, not a setup problem
- Implement the feature
- Verify the test passes — re-run
mix test path/to/file_test.exsand confirm green
See assets/tdd_checklist.md for a copy-paste RED/GREEN/REFACTOR checklist and pre-commit quality gate.
Test Module Setup
See assets/spec_templates.md for copy-paste DataCase, ConnCase, LiveView, isolated-LiveView, and ChannelCase test templates.
DataCase — for context and schema tests
defmodule MyApp.AccountsTest do
use MyApp.DataCase, async: true
alias MyApp.Accounts
import MyApp.AccountsFixtures
end
ConnCase — for LiveView and controller tests
defmodule MyAppWeb.UserLiveTest do
use MyAppWeb.ConnCase, async: true
import Phoenix.LiveViewTest
import MyApp.AccountsFixtures
end
Fixture Pattern
Define all test data in test/support/fixtures/:
defmodule MyApp.AccountsFixtures do
def user_fixture(attrs \\ %{}) do
{:ok, user} =
attrs
|> Enum.into(%{
email: "user#{System.unique_integer([:positive])}@example.com",
password: "hello world!"
})
|> MyApp.Accounts.register_user()
user
end
end
Context Test Skeleton
describe "create_post/1" do
test "with valid attrs creates a post" do
assert {:ok, %Post{} = post} = Blog.create_post(%{title: "Hello"})
assert post.title == "Hello"
end
test "with invalid attrs returns error changeset" do
assert {:error, %Ecto.Changeset{} = changeset} = Blog.create_post(%{})
assert %{title: ["can't be blank"]} = errors_on(changeset)
end
end
LiveView Test Skeleton
describe "index" do
test "lists posts", %{conn: conn} do
post = post_fixture()
{:ok, _lv, html} = live(conn, ~p"/posts")
assert html =~ post.title
end
test "unauthorized user is redirected", %{conn: conn} do
{:error, {:redirect, %{to: path}}} = live(conn, ~p"/admin/posts")
assert path == ~p"/login"
end
end
describe "create" do
test "saves post with valid attrs", %{conn: conn} do
{:ok, lv, _html} = live(conn, ~p"/posts/new")
lv
|> form("#post-form", post: %{title: "New Post"})
|> render_submit()
assert has_element?(lv, "p", "Post created")
end
test "shows errors with invalid attrs", %{conn: conn} do
{:ok, lv, _html} = live(conn, ~p"/posts/new")
lv
|> form("#post-form", post: %{title: ""})
|> render_submit()
assert has_element?(lv, "p.alert", "can't be blank")
end
end
Changeset Test Skeleton
describe "changeset/2" do
test "valid attrs" do
assert %Ecto.Changeset{valid?: true} = Post.changeset(%Post{}, %{title: "Hello"})
end
test "requires title" do
changeset = Post.changeset(%Post{}, %{})
assert %{title: ["can't be blank"]} = errors_on(changeset)
end
end
Setup Chaining
Use setup [:func1, :func2] to compose reusable setup functions:
defmodule MyAppWeb.PostLiveTest do
use MyAppWeb.ConnCase, async: true
import MyApp.AccountsFixtures
import MyApp.BlogFixtures
setup [:register_and_log_in_user, :create_post]
test "owner can edit post", %{conn: conn, post: post} do
{:ok, lv, _html} = live(conn, ~p"/posts/#{post}/edit")
assert has_element?(lv, "#post-form")
end
defp create_post(%{user: user}) do
%{post: post_fixture(user_id: user.id)}
end
end
Timestamp Testing
❌ Bad — hardcoded date will eventually be in the past:
assert post.published_at == ~U[2026-01-15 12:00:00Z]
✅ Good — relative to now:
now = DateTime.utc_now(:second)
assert DateTime.diff(post.inserted_at, now, :second) < 5
✅ Good — build relative dates for filtering/sorting:
past = DateTime.add(DateTime.utc_now(:second), -7, :day)
future = DateTime.add(DateTime.utc_now(:second), 7, :day)
old_post = post_fixture(published_at: past)
new_post = post_fixture(published_at: future)
assert Blog.list_published_posts() == [old_post]
Troubleshooting Common Failures
| Symptom | Fix |
|---|---|
ownership timeout / DBConnection.OwnershipError |
Set async: false |
LiveView cannot find ownership process |
Set async: false (LiveView tests must not be async) |
Application.put_env leaking between tests |
Use async: false; restore original value in on_exit callback |
| Flaky timestamp assertions | Replace hardcoded datetimes with DateTime.diff/3 (see Timestamp Testing above) |
| Unexpected redirect in LiveView | Confirm test user has required role/session via register_and_log_in_user setup |
When Not to Use
Do not invoke this skill for: pure-function unit tests with no DB/external side effects (use plain ExUnit), property-based testing (property-based-testing skill), benchmarking (benchee-profiling skill), Mox patterns for external services, or LiveView streams (liveview-streams skill).
Common Pitfalls
| ❌ Don't | ✅ Do |
|---|---|
async: true on LiveView or shared-row DB tests |
Set async: false when tests share mutable state |
assert html =~ "Save" for structure checks |
has_element?(lv, "#post-form button", "Save") |
| Build test data inline in every test | Define reusable fixtures in test/support/ |
assert post.published_at == ~U[2026-01-15 12:00:00Z] |
Assert relative to DateTime.utc_now/1 with DateTime.diff/3 |
| Test only the happy path | Always add the unauthorized/invalid-attrs case |
| Reuse a hardcoded email across tests | "user#{System.unique_integer([:positive])}@example.com" |
Integration
| Predecessor | This Skill | Successor |
|---|---|---|
| ecto-essentials | testing-essentials | property-based-testing |
| phoenix-liveview-essentials | testing-essentials | code-quality |
Companion skills: property-based-testing, benchee-profiling, tdd, code-quality.