# Write Tests

> Fast Vitest component tests (~60ms target): snapshots, scoped container queries, mocked heavy deps, fake timers, deterministic async. Use when writing or adding tests.

- Skill: `maxwellcohen/write-tests` (Agent Skill)
- Install (CLI): `npx skillmds@latest add maxwellcohen/write-tests`
- Raw SKILL.md: https://api.skillmd.com/api/skills/maxwellcohen/write-tests/raw
- Safety review: pending
- Works with: Claude Code, Claude.ai, OpenAI Codex
- Category: Web & Frontend
- Author: MaxwellCohen (https://skillmd.com/u/maxwellcohen)
- Updated: 2026-09-22
- Page: https://skillmd.com/skills/maxwellcohen/write-tests

---


# Write Tests

## Principles

- **Stack:** Vitest + RTL (`render`, `act`, `within`, `fireEvent` / `userEvent`).
- **Speed-first:** Snapshots for renders; **`container` + scoped `querySelector` / `querySelectorAll`** for interactions. Avoid unscoped `screen.getByRole` and `screen.getByText` (they walk the whole tree). RTL recommends role/text for a11y-skipping them here is **intentional** for speed; use **`within(subtree).getBy*`** or E2E when a11y is the contract.
- **~60ms per test** is the goal (CI varies). Use `--reporter=verbose` or Vitest slow-test thresholds; fix slowness with mocks and async patterns below. Do not merge consistently slow tests without a short note why.
- **Mock when** the module is heavy: `@/components/ui/*`, `lucide-react`, `recharts`, or a large internal file when you only need one export. **Do not mock** the component under test or tiny utilities.
- **Slow forms:** Extract submit/action logic to a module and unit test it; **mock that module** in component tests so they only assert UI wiring (e.g. error vs success), not full validation matrices.

## Mocking

Put **`vi.mock` at the top** of the file, before importing the component under test. Replace design-system and chart/icon pieces with shallow passthroughs (`div`, `button`, `table`); **keep callbacks** (`onClick`, `onCheckedChange`, etc.) when you assert behavior. For structure-only tests, mock **Card/Table** as thin wrappers; mock **heavy child widgets** (data tables, dialogs) when testing parents.

**Partial mock:** `importOriginal` + spread, override one export with `vi.fn()`. **Reuse mocks** across files via `import("./__mocks__/...").then((m) => m.factory)`. **Isolation:** if one file mocks a module but another needs the real one, use a factory that `return actual` (pass-through) where appropriate.

**Never use `vi.hoisted()`.** Use `vi.fn()` inside the factory, `importOriginal`, or a shared `__mocks__` module.

```tsx
vi.mock("@/lib/form-action", async (importOriginal) => {
  const actual = await importOriginal();
  return { ...actual, submit: vi.fn().mockResolvedValue({ ok: true }) };
});

vi.mock("lucide-react", () => ({
  Check: () => <span data-testid="check" />,
}));
```

## Timers and async

Enable **fake timers** only when the code under test uses `setTimeout`, `setInterval`, or debounce-not for `useTransition`, `useActionState`, or purely microtask async.

```tsx
beforeEach(() => vi.useFakeTimers());
afterEach(() => vi.useRealTimers());
```

Flush effects and microtasks with **`await act(async () => {})`**. Advance known delays with **`await act(async () => { await vi.advanceTimersByTimeAsync(1); })`**. Prefer that over **`waitFor` / `findBy*`** when the sequence is deterministic.

| Scenario                                        | Use                                                                |
| ----------------------------------------------- | ------------------------------------------------------------------ |
| Flush effects (`useEffect` that sets state)     | `await act(async () => {})`                                        |
| Known delays (form processing)                  | `await act(async () => { await vi.advanceTimersByTimeAsync(1); })` |
| Microtask flush (sync validation, mocked fetch) | `await act(async () => {})`                                        |

## Snapshots and interactions

- Separate snapshots (or states) for loading, error, key variants, etc. Prefer **`toMatchInlineSnapshot`** or a few **targeted `expect`s** when a full `container` snapshot is too noisy.
- **Disambiguate:** do not use a lone `container.querySelector("button")` when many match-start from a stable wrapper (`data-testid`, form, section), then `querySelectorAll` + `find` by text, or **`within(thatNode).getByRole(...)`**.
- Default to **`fireEvent`**; use **`userEvent.setup()`** for comboboxes, realistic keyboard paths, or paste.

## Checklist

- [ ] Checked per-test time (`--reporter=verbose`); slow cases fixed or documented for CI variance
- [ ] A11y-sensitive surfaces have role/E2E coverage if this skill's shortcuts are insufficient
- [ ] Heavy deps mocked at file top; **no `vi.hoisted()`**
- [ ] Fake timers match real timer usage in the code under test
- [ ] Noisy trees use inline snapshots or focused assertions
- [ ] Run `pnpm test -- -u <test-file>` only when updating snapshots intentionally

