Component Test Writer
A test suite is worth what it catches minus what it costs to maintain. Tests coupled to implementation break on every refactor while catching nothing, which is how teams end up with 90% coverage and no confidence. This skill writes tests against behaviour the user can observe.
Choose the level first
| Level | Tool | Use for | Keep it |
|---|---|---|---|
| Unit | Vitest | pure functions, reducers, formatters, validation | many, fast |
| Component | Vitest + RTL | rendering, interaction, conditional UI, a11y contract | most of the suite |
| Integration | RTL + MSW | data fetching, forms with real submit, routing | a solid layer |
| E2E | Playwright | critical revenue paths across real pages | few, high value |
The common failure is inversion: a hundred shallow component tests and no test that a user can actually check out. Ask what breaking would be most expensive, and start there.
Component tests
Query priority. Use getByRole first, then getByLabelText for form fields, then
getByText. getByTestId is the last resort. This is not stylistic — role-based queries
fail when the component becomes inaccessible, so the suite doubles as an accessibility
regression net.
Interact as a user. userEvent over fireEvent: it dispatches the full event sequence
(pointerdown, focus, keydown, input) that real interaction produces, so it catches bugs
fireEvent.change walks straight past.
const user = userEvent.setup();
await user.type(screen.getByLabelText(/email/i), 'test@example.com');
await user.click(screen.getByRole('button', { name: /sign in/i }));
expect(await screen.findByRole('alert')).toHaveTextContent(/invalid credentials/i);
Assert on output, never internals. No assertions on state variables, hook call counts, class names, or child component props. If the rendered output and the callbacks are correct, the internals are free to change — that is the entire value of the test.
Async. findBy* for appearance, waitForElementToBeRemoved for disappearance, and
waitFor only when neither fits. Never await new Promise(r => setTimeout(r, 500)); a
fixed sleep is either flaky on slow CI or wasted time on fast CI.
Network with MSW
Mock at the network boundary, not the module boundary. Mocking axios or a useQuery hook
tests your mock; intercepting HTTP tests your code.
export const server = setupServer(
http.get('/api/users/:id', ({ params }) =>
HttpResponse.json({ id: params.id, name: 'Ada Lovelace' }),
),
);
beforeAll(() => server.listen({ onUnhandledRequest: 'error' }));
afterEach(() => server.resetHandlers());
afterAll(() => server.close());
onUnhandledRequest: 'error' is worth setting from day one — it turns a silently missing
handler into a clear failure instead of a mysterious empty render.
Override per test for the paths that matter more than the happy one:
server.use(http.get('/api/users/:id', () => new HttpResponse(null, { status: 500 })));
What to test in a component
Cover these five and stop; anything beyond is usually restating the implementation:
- It renders the meaningful content for representative props
- Interaction produces the expected output or callback
- Conditional branches — empty, loading, error, permission-denied
- Accessibility contract — roles, names, and keyboard operability
- Edge inputs — empty list, very long string, zero, null
Skip: prop-type validation, that a library works, styling, and trivially derived values.
Hooks
Test through a component when the hook is used by one, since that is how it will actually
run. Use renderHook only for genuinely standalone hooks:
const { result } = renderHook(() => useCounter(5));
act(() => result.current.increment());
expect(result.current.count).toBe(6);
Playwright
Reserve for flows where failure costs real money: signup, checkout, payment, the primary task of the app. Each one is expensive to run and to maintain.
test('completes checkout', async ({ page }) => {
await page.goto('/cart');
await page.getByRole('button', { name: 'Checkout' }).click();
await page.getByLabel('Card number').fill('4242424242424242');
await page.getByRole('button', { name: 'Pay' }).click();
await expect(page.getByText('Order confirmed')).toBeVisible();
});
Use web-first assertions (toBeVisible, toHaveText) — they retry automatically. Never
waitForTimeout. Seed state through the API rather than the UI so a login page change does
not break twenty unrelated tests.
Diagnosing flaky tests
Flakiness is almost always one of five causes. Check in this order:
- Fixed timeouts → replace with
findBy*or a web-first assertion - Shared state between tests → reset handlers, storage, and the query cache in
afterEach - Unawaited async → a missing
awaiton auserinteraction or an assertion - Real timers with animations →
vi.useFakeTimers()or disable animations in E2E - Test order dependence → run with
--shuffle; a suite that fails when shuffled has hidden coupling
A quarantined flaky test is worse than a deleted one — it trains the team to ignore red.
Setup
references/setup.md has the Vitest config, the shared setup file, a provider-aware custom
render, MSW wiring, and Playwright config with auth-state reuse. Read it before writing the
first test in a repo that has no test infrastructure yet — the provider render and the
per-test QueryClient in particular prevent a class of order-dependent failures that is
painful to debug later.
Output format
Deliver: the test file, any MSW handlers or fixtures it needs, the run command, and one short note on what is deliberately not covered and why. That last line is what makes a reviewer trust the rest.
Aim for coverage of behaviour, and treat a line-coverage percentage as a diagnostic rather than a target — chasing the number produces tests that assert nothing.