Testing
Test behavior, not implementation
Section titled “Test behavior, not implementation”The dominant approach for testing React components is React Testing Library (RTL), usually run on Vitest (or Jest). Its guiding principle:
The more your tests resemble the way your software is used, the more confidence they give you.
That means you don’t test internal state, prop names, or which hooks a component calls. You render the component, interact with it the way a user would (click, type), and assert on what the user can see. When you refactor the internals — swap useState for useReducer, extract a hook — behavior-based tests keep passing, because the behavior didn’t change. Implementation-based tests would break for no real reason.
A widget test, end to end
Section titled “A widget test, end to end”import { render, screen } from '@testing-library/react';import userEvent from '@testing-library/user-event';import { expect, test } from 'vitest';import Counter from './Counter';
test('increments when the button is clicked', async () => { const user = userEvent.setup(); render(<Counter />);
// Query the way a user finds things: by role and accessible name. const button = screen.getByRole('button', { name: /count: 0/i });
await user.click(button);
// Assert on what the user sees, not on internal state. expect(screen.getByRole('button', { name: /count: 1/i })).toBeInTheDocument();});The important habits:
- Query by role/label/text, in that priority —
getByRoleis best because it mirrors how assistive tech (and users) find elements, which also nudges you toward accessible markup. - Use
userEvent, not rawfireEvent, for interactions — it simulates real user actions (focus, key sequences) more faithfully. - Assert on the rendered output (
toBeInTheDocument, visible text), never oncomponent.state.
The testing pyramid, React edition
Section titled “The testing pyramid, React edition”| Level | Tool | What it covers |
|---|---|---|
| Unit | Vitest | Pure functions, custom hooks (renderHook), reducers |
| Component | RTL + Vitest | A component’s behavior in isolation (most of your tests) |
| End-to-end | Playwright / Cypress | Real browser, full user flows across pages |
flowchart TB e2e["End-to-end (few) Playwright — real browser, whole flows"] comp["Component (many) RTL + Vitest — behavior in isolation"] unit["Unit (some) Vitest — pure logic, hooks, reducers"] e2e --> comp --> unit
Most of your effort lives in the middle — RTL component tests — because that’s where React logic actually is. A few end-to-end tests cover the critical happy paths across the real app.