# Testing Essentials

> MANDATORY for ALL test files. Invoke before writing any _test.exs file. Covers DataCase/ConnCase setup, fixture patterns, LiveView tests, changeset tests, async safety, setup chaining, timestamp testing, and TDD workflow. Trigger words: test, mix test, DataCase, ConnCase, fixture, LiveView test, assert, ExUnit.

- Skill: `igmarin/testing-essentials` (Agent Skill, multi-file: 3 files)
- Install (CLI): `npx skillmds@latest add igmarin/testing-essentials`
- Raw SKILL.md: https://api.skillmd.com/api/skills/igmarin/testing-essentials/raw
- Safety review: pending
- Works with: Claude Code, Claude.ai, OpenAI Codex
- Category: Productivity
- License: MIT
- Author: igmarin (https://skillmd.com/u/igmarin)
- Updated: 2026-09-17
- Page: https://skillmd.com/skills/igmarin/testing-essentials

---


# Testing Essentials


Canonical FP bar: [`docs/fcis-engineering-rules.md`](../../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

```elixir
test "discount" do
  order = insert(:order)
  assert Orders.apply_discount(order.id, 10).total == 90
end
```

✅ **Good:** pure core test is side-effect free

```elixir
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:

1. **Check existing fixtures** — inspect `test/support/fixtures/` for relevant fixtures before creating new ones
2. **Create fixture if needed** — add to the appropriate fixtures module (see Fixture Pattern below)
3. **Verify compilation** — run `mix test` to confirm the fixture compiles before writing any tests
4. **Write the failing test** — implement the test case; run `mix test path/to/file_test.exs` and confirm it fails with a meaningful message (not a compile error)
5. **Verify the failure message** — the failure should describe a missing behaviour, not a setup problem
6. **Implement the feature**
7. **Verify the test passes** — re-run `mix test path/to/file_test.exs` and confirm green

See [`assets/tdd_checklist.md`](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`](assets/spec_templates.md) for copy-paste DataCase, ConnCase, LiveView, isolated-LiveView, and ChannelCase test templates.

### DataCase — for context and schema tests

```elixir
defmodule MyApp.AccountsTest do
  use MyApp.DataCase, async: true

  alias MyApp.Accounts
  import MyApp.AccountsFixtures
end
```

### ConnCase — for LiveView and controller tests

```elixir
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/`:

```elixir
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

```elixir
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

```elixir
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

```elixir
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:

```elixir
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:**
```elixir
assert post.published_at == ~U[2026-01-15 12:00:00Z]
```

✅ **Good — relative to now:**
```elixir
now = DateTime.utc_now(:second)
assert DateTime.diff(post.inserted_at, now, :second) < 5
```

✅ **Good — build relative dates for filtering/sorting:**
```elixir
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`.

