Testing React components
Vitest with React Testing Library, queries a user would use, user-event for real interactions, mocking fetch, and accessibility assertions.
Query the way a user perceives
import { render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { expect, test, vi } from 'vitest'
import { SearchBox } from './SearchBox'
test('shows the results the API returned', async () => {
vi.spyOn(globalThis, 'fetch').mockResolvedValue(
new Response(JSON.stringify([{ id: 1, title: 'Caching' }]), {
status: 200,
headers: { 'content-type': 'application/json' }
})
)
const user = userEvent.setup()
render(<SearchBox />)
await user.type(screen.getByRole('searchbox'), 'cache')
await user.click(screen.getByRole('button', { name: /search/i }))
expect(await screen.findByText('Caching')).toBeInTheDocument()
})| Query | Use it for | When nothing matches |
|---|---|---|
getByRole | Anything a user can perceive - the default choice | Throws immediately |
findByRole | Something that appears asynchronously | Rejects after the timeout |
queryByRole | Asserting that something is absent | Returns null - the only non-throwing query |
getByLabelText | Form fields | Throws |
getByText | Non-interactive content | Throws |
getByTestId | Last resort when no accessible handle exists | Throws |
A test written through roles and labels fails when the component becomes inaccessible, which is exactly the feedback you want. A test written against CSS classes or internal state passes while the feature is broken for a real user.
Interactions, async and mocks
// user-event simulates a real browser: focus, typing, pointer and keyboard events
await user.click(screen.getByRole('checkbox', { name: /notify me/i }))
await user.tab()
await user.keyboard('{Enter}')
// waitFor is for expectations that settle later than the act of interacting
await waitFor(() => expect(screen.getByRole('status')).toHaveTextContent('Saved'))
// never await a getBy* query: it is synchronous and will fail before the update lands
// mock at the module boundary for a component that wraps a client
vi.mock('./api', () => ({
saveProfile: vi.fn().mockResolvedValue({ ok: true })
}))
// reset between tests so one test cannot leak a mock's state into the next
beforeEach(() => vi.clearAllMocks())- Mock at the boundary the component actually uses - the module or the network - and keep the component's own logic under test.
- One assertion per behaviour reads better than a long test that re-renders and clicks through five features; a failure should name one broken behaviour.
- Test what the user can observe: rendered text, roles, focus and the calls made to the outside world. Never assert on a state variable's value.
- Prefer a real, tiny fixture over a large mock object: object fixtures rot quietly and hide the shape the component needs.
Accessibility and a smoke test
// assert the accessibility contract, not just the DOM structure
expect(screen.getByRole('dialog')).toHaveAttribute('aria-modal', 'true')
expect(screen.getByRole('button', { name: 'Close' })).toBeEnabled()
expect(screen.getByLabelText('Email')).toHaveFocus()
// run an accessibility audit over the rendered tree
import { axe } from 'vitest-axe'
test('the signup form has no accessibility violations', async () => {
const { container } = render(<SignupForm />)
expect(await axe(container)).toHaveNoViolations()
})// a Playwright smoke test covers the built application end to end
import { expect, test } from '@playwright/test'
test('the home page loads and links to the guides', async ({ page }) => {
await page.goto('http://localhost:4173') // the preview server output
await expect(page.getByRole('heading', { name: /guides/i })).toBeVisible()
await page.getByRole('link', { name: /guides/i }).click()
await expect(page).toHaveURL(/guides/)
})💡
A small number of end-to-end tests over the built output catches what unit tests cannot: a broken asset path, a missing rewrite rule, a router that works only in development. Keep it to the critical journeys so it stays fast enough to run on every pull request.
FAQ
Should a test assert on implementation details?
No. Assert on what is rendered and what effects the component produces - roles, labels, visible text, network calls. Tests bound to state variables and class names break on every refactor and prove nothing about the feature.
Mock fetch or a network library?
For a single component, spying on
fetch is enough. For a suite, intercept at the network layer with MSW: the component keeps using real fetch semantics and the same handlers serve tests and local development.Related
Performance, memoisation and transitions Production builds, environment config and deployment
Last refreshed 2026-09-18.