Write Tests
Principles
- Stack: Vitest + RTL (
render,act,within,fireEvent/userEvent). - Speed-first: Snapshots for renders;
container+ scopedquerySelector/querySelectorAllfor interactions. Avoid unscopedscreen.getByRoleandscreen.getByText(they walk the whole tree). RTL recommends role/text for a11y-skipping them here is intentional for speed; usewithin(subtree).getBy*or E2E when a11y is the contract. - ~60ms per test is the goal (CI varies). Use
--reporter=verboseor 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.
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.
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
toMatchInlineSnapshotor a few targetedexpects when a fullcontainersnapshot is too noisy. - Disambiguate: do not use a lone
container.querySelector("button")when many match-start from a stable wrapper (data-testid, form, section), thenquerySelectorAll+findby text, orwithin(thatNode).getByRole(...). - Default to
fireEvent; useuserEvent.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