React Testing Library changed how we test interfaces: the focus shifts from implementation to the behavior users see and interact with. This guide covers principles, queries, real interactions, async testing, mocking, and the coverage trap.
The principle: test what the user sees
The core idea is simple: tests should validate visible behavior, not internal structure. It doesn't matter if the component uses useState, useReducer, or classes. What matters: can the user see the element? Can they interact? Do they get clear feedback?
Instead of asserting "the button has the aria-label='Save' attribute", the test checks "the button with text 'Save' exists and responds to clicks". This ensures internal refactors don't break tests - only changes to user-perceived behavior do.
Queries: finding elements like a user would
Queries are categorized by priority, from most to least recommended:
getBy: for elements that must always exist. Throws if not found.queryBy: for optional elements (e.g., error messages). Doesn't throw.findBy: for async elements. Returns a promise.
Daily use: getBy and findBy. queryBy validates initial absence.
Practical example:
import { render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import TaskForm from '../TaskForm'
test('shows error when title is empty', async () => {
render(<TaskForm onSubmit={vi.fn()} />)
const createButton = screen.getByRole('button', { name: /create task/i })
await userEvent.click(createButton)
const errorMessage = screen.getByText(/title is required/i)
expect(errorMessage).toBeInTheDocument()
})
Search by visible, accessible text - not by id or CSS selector. This makes tests resilient to HTML structure or class changes.
Watch out for getByLabelText on fields without visible labels. If only placeholder exists (<input placeholder="Title" />), use getByPlaceholderText. Placeholder is a hint, not a label.
User events: simulate real action, not DOM events
fireEvent.click ignores browser behaviors: focus, keyboard events, accessibility. Use @testing-library/user-event which simulates real interactions - a click fires mouseenter, focus, mousedown, mouseup, click.
import { render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import ModuleNavigation from '../ModuleNavigation'
test('navigates to next module and keeps focus', async () => {
const user = userEvent.setup()
render(<ModuleNavigation currentModule={1} totalModules={5} />)
const nextButton = screen.getByRole('button', { name: /next module/i })
await user.click(nextButton)
const activeModuleButton = screen.getByRole('button', {
name: /module 2/i,
selected: true
})
expect(activeModuleButton).toHaveFocus()
})
Essential for accessibility: if focus doesn't move correctly, screen reader users hit real barriers.
Async testing: when the world doesn't stop
Modern interfaces are async: API loads, animations, events. Testing Library provides tools without hacks:
await screen.findByText('Success'): waits until text appears.waitFor(() => expect(...)): repeats assertion until pass or timeout.
test('shows loading during sync and updates after success', async () => {
const user = userEvent.setup()
render(<SyncButton />)
const syncButton = screen.getByRole('button', { name: /sync/i })
await user.click(syncButton)
const loadingButton = await screen.findByRole('button', { name: /syncing\.\.\./i })
expect(loadingButton).toBeDisabled()
await waitFor(() => {
expect(screen.getByRole('button', { name: /sync/i })).toBeInTheDocument()
})
})
findBy and waitFor react to real DOM, not fixed time - fast, reliable tests.
Mocking API: control without real dependencies
Testing components that consume APIs requires isolation. msw (Mock Service Worker) intercepts requests at network layer, simulating responses without a server.
// setupTests.js
import { setupServer } from 'msw/node'
import { handlers } from './mocks/handlers'
export const server = setupServer(...handlers)
beforeAll(() => server.listen())
afterEach(() => server.resetHandlers())
afterAll(() => server.close())
// mocks/handlers.js
import { rest } from 'msw'
export const handlers = [
rest.get('/api/models', (req, res, ctx) => {
return res(ctx.status(200), ctx.json([
{ id: 'llama-2', name: 'Llama 2' },
{ id: 'mistral-7b', name: 'Mistral 7B' }
]))
})
]
msw advantage: intercepts both fetch and axios without changing component logic. Prefer over vi.mock which tests implementation, not visible behavior.
Coverage: useful metric, but with caveats
Coverage measures lines executed, not whether the right behavior was tested. Easy to hit 100% testing only render. Doesn't guarantee user experience.
Practical use: filter critical components (checkout, payments, forms) and verify they have tests. The exact number is secondary - what matters is validating real flows with accessible messages.
Conclusion: write tests that last
Testing with React Testing Library documents behavior so others (or future you) know exactly how code should behave.
Weak vs strong test:
- Weak: "Button must fire
handleClick" - Strong: "Clicking 'Save' shows a confirmation message"
The strong test survives refactoring to useReducer. User-perceived behavior is what matters.
Quick reference:
- Start with accessible text/role queries (
getByRole,getByText) - Use
userEventfor clicks, typing, navigation - Prefer
findByandwaitForfor async - Mock APIs with
mswfor full fidelity - Forget coverage percentage. Focus on real user flows
If a test breaks just from renaming a button or changing an id, it tests implementation - not behavior. Refactor until it only fails when the user sees something broken.
Post glossary
- react
- A JavaScript library for building user interfaces from reusable components, maintained by Meta.
- css
- Cascading Style Sheets: the language used to style web pages, controlling colors, fonts, layout and responsiveness.
- html
- HyperText Markup Language: the markup language that defines the structure and content of a web page.
- dom
- Document Object Model: the browser's tree representation of a page, letting JavaScript read and modify its content.
- event
- A relevant system occurrence (click, order, message) that triggers asynchronous actions, the basis of event-driven architectures.
- api
- Application Programming Interface: a set of rules that lets one program talk to another, exposing data and features in a standardized way.
- service
- A software unit with a well-defined responsibility, exposed through an interface and reusable by other system parts.
