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()
})
QueryUse it forWhen nothing matches
getByRoleAnything a user can perceive - the default choiceThrows immediately
findByRoleSomething that appears asynchronouslyRejects after the timeout
queryByRoleAsserting that something is absentReturns null - the only non-throwing query
getByLabelTextForm fieldsThrows
getByTextNon-interactive contentThrows
getByTestIdLast resort when no accessible handle existsThrows

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.

Performance, memoisation and transitions Production builds, environment config and deployment

Last refreshed 2026-09-18.