React Testing: A Practical Tutorial
Learn to test React behavior with React Testing Library, user-event, accessible queries, and async assertions, with a runnable example and setup guidance.
A practical React component test renders the component, finds controls the way a user or assistive technology would, performs an interaction, waits for asynchronous results, and checks what becomes visible. React Testing Library renders and queries the DOM; user-event models interactions; a runner such as Jest or Vitest discovers and runs the test.
The example below tests a form submission. It uses a labeled textbox, a button with an accessible name, awaited user actions, and an async query for the resulting status. The component and test are illustrative: adapt names and behavior to your application.
1. Understand the testing layers
- React Testing Library (RTL) renders a React tree into a DOM container and gives you queries for the rendered DOM. Its goal is to keep tests focused on observable behavior. Its guiding principle is: “The more your tests resemble the way your software is used, the more confidence they can give you.” Testing Library’s introduction.
user-eventexpresses common actions such as typing and clicking. It models a fuller interaction sequence than dispatching a single event.- A test runner, such as Jest or Vitest, discovers and runs tests, and provides a test environment and assertions. RTL is not a runner and can work with different testing frameworks.
jest-domadds DOM-focused matchers, for exampletoBeDisabledandtoHaveTextContent.
These layers complement one another. Pick the runner that fits your project, then set up RTL and its matchers to work with it. Testing Library documents a preference for Jest, while its example also notes Vitest support for jest-dom. Confirm setup against your project’s React version, runner, package manager, and lockfile rather than copying a version number from a generic tutorial.
2. Install and configure the project
Install the packages appropriate to your existing runner and package manager. The current RTL introduction shows installing @testing-library/react with @testing-library/dom; the DOM package is a peer dependency starting with RTL v16. Check the current official setup documentation and your project’s lockfile before choosing versions.
You will also need a DOM-capable test environment. Configure your runner to provide one, and load jest-dom in the test setup file if you want its matchers. Exact configuration varies by runner and project; follow the runner’s current documentation rather than mixing Jest and Vitest configuration syntax.
For example, a project setup file can import the matchers:
import '@testing-library/jest-dom'
Make sure that file is included by your runner’s setup configuration. The test below also imports the matcher package directly to make its dependency visible in one self-contained example. Use either the project setup file or a per-test import according to your project’s convention.
3. Write a small component and behavior test
Here is a minimal form whose submit handler displays a greeting. The component keeps the example independent of a network or backend.
import { useState } from 'react'
export default function GreetingForm() {
const [name, setName] = useState('')
const [message, setMessage] = useState('')
function handleSubmit(event) {
event.preventDefault()
const trimmedName = name.trim()
if (trimmedName) {
setMessage(`Hello, ${trimmedName}`)
}
}
return (
<form onSubmit={handleSubmit}>
<label htmlFor="name">Name</label>
<input
id="name"
value={name}
onChange={(event) => setName(event.target.value)}
/>
<button type="submit">Submit</button>
{message && <p role="status">{message}</p>}
</form>
)
}
The test asks whether the user-visible behavior works: entering a name and submitting the form makes a greeting appear. It does not inspect component state or call the handler directly.
import { render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import '@testing-library/jest-dom'
import GreetingForm from './GreetingForm'
test('shows a greeting after submission', async () => {
const user = userEvent.setup()
render(<GreetingForm />)
await user.type(screen.getByRole('textbox', { name: /name/i }), 'Ada')
await user.click(screen.getByRole('button', { name: /submit/i }))
expect(await screen.findByRole('status')).toHaveTextContent(/hello, ada/i)
})
Run the test with your project’s configured test command. The exact command depends on the runner and package scripts. This snippet is an illustrative pattern; it is not a claim that a package setup or test was executed.
- Create the
userinstance withuserEvent.setup()before rendering. - Render the component with
render. - Find the textbox by its label and the button by its role and accessible name.
- Await each interaction.
user-eventinteraction methods are asynchronous. - Wait for the status to appear with
findByRole, then assert the visible text.
4. Choose queries that reflect the interface
Prefer queries based on semantics exposed to users and assistive technology. They make the test read like a task and can reveal missing labels or incorrect roles.
| Query | Use it when | Example |
|---|---|---|
getByRole |
The element should already exist; usually use a role and accessible name. | screen.getByRole('button', { name: /save/i }) |
getByLabelText |
You need to find a form field through its label. | screen.getByLabelText(/email/i) |
findBy… |
The matching element should appear after asynchronous work. | await screen.findByRole('alert') |
queryBy… |
You need to check that an element is absent without an immediate missing-element error. | expect(screen.queryByRole('dialog')).not.toBeInTheDocument() |
getByTestId |
A meaningful user-facing query is impractical. | screen.getByTestId('chart-canvas') |
Use a test ID as an escape hatch, not as the default for controls that already have a role, label, or accessible name. A query that fails because a button has no accessible name may point to a real usability issue.
RTL queries can be accessed through the object returned by render, but screen is convenient for queries against the document. See the RTL API for render options and query details.
5. Use realistic interactions
Use user-event for ordinary actions such as typing, clicking, clearing text, selecting options, and uploading files. It simulates a sequence of events and checks whether typical actions are possible; for example, it accounts for focus and avoids interactions a browser would prevent on hidden or disabled controls. Await its methods, as documented in the user-event introduction and utility API.
const user = userEvent.setup()
await user.clear(screen.getByRole('textbox', { name: /email/i }))
await user.type(screen.getByRole('textbox', { name: /email/i }), 'ada@example.com')
await user.selectOptions(screen.getByRole('combobox', { name: /country/i }), 'NZ')
await user.click(screen.getByRole('button', { name: /save/i }))
fireEvent remains useful when you need to dispatch a specific low-level DOM event that user-event does not implement. For regular user actions, a single dispatched event may omit other parts of the interaction sequence, such as focus changes. See the user-event documentation for its comparison with fireEvent.
6. Wait for asynchronous UI
When a state update or request makes content appear later, wait for the expected content with an async query, then assert its meaningful result. The official RTL example follows this pattern and also checks that a button becomes disabled after loading.
await user.click(screen.getByRole('button', { name: /load profile/i }))
const heading = await screen.findByRole('heading', { name: /profile/i })
expect(heading).toHaveTextContent(/ada/i)
expect(screen.getByRole('button', { name: /load profile/i })).toBeDisabled()
Use getBy… for elements expected now and findBy… for elements expected after an asynchronous change. Avoid adding arbitrary sleeps to make a test pass: waiting for the expected observable condition is more closely tied to the behavior under test.
7. Mock API communication at the request boundary
If a component depends on an API, keep it using its normal request path and mock the HTTP interaction at the request boundary. Testing Library’s example recommends Mock Service Worker (MSW) for modeling API communication declaratively rather than stubbing window.fetch or relying on third-party adapters.
Use controlled responses to cover distinct visible states:
- Loading: the request is pending and the interface communicates that state.
- Success: the expected content appears and any relevant controls update.
- Error: the interface shows a useful error and gives the user an appropriate next action.
Keep assertions on the rendered result. A request mock is test setup; the user-visible response is what the behavior test should verify.
8. Share provider setup with a custom render helper
When many tests need the same router, context, or other common provider, define a project-specific render helper that wraps the component. RTL’s render accepts a wrapper option; see the API documentation.
import { render } from '@testing-library/react'
import { AppProviders } from './AppProviders'
function renderWithProviders(ui, options) {
return render(ui, { wrapper: AppProviders, ...options })
}
export { renderWithProviders }
Keep the helper aligned with your actual providers and expose normal render options when tests need them. Avoid turning it into a framework that hides what a test renders or how its important dependencies are configured.
9. Keep tests resilient during refactors
- Assert what a person can observe: content, labels, status, enabled or disabled state, and available actions.
- Prefer role, accessible name, and label queries over selectors tied to internal markup.
- Use test IDs where user-facing semantics are not a practical way to find the target.
- Test a behavior at the component boundary; avoid asserting private state or calling event handlers directly.
- Keep each test focused on a clear user outcome, while including the setup necessary to reach it.
RTL wraps act() in most of its usual APIs, so ordinary RTL tests usually do not need manual act() calls. For advanced cases, follow the needs of the specific runner and React setup. React’s deprecation guidance for react-dom/test-utils points readers toward alternatives including RTL’s render; do not teach deprecated test-utils APIs as the default.
10. Troubleshoot common failures
| Symptom | Likely cause | Fix |
|---|---|---|
| “Unable to find an accessible element” | The query runs before async content appears, or the component has no expected role, name, or label. | Use findBy… for content that appears later. Inspect the rendered semantics and add a proper label or accessible name if it is missing. |
| “Found multiple elements” | The query matches more than one control. | Use a more specific accessible name or scope the query to the relevant section. Do not select an arbitrary match just to silence the error. |
| Assertion runs before the update | The test did not wait for asynchronous UI. | Await the user action and use an async query for the expected element before asserting its content or state. |
toBeInTheDocument or another DOM matcher is undefined |
jest-dom was not imported or the setup file was not loaded. |
Import @testing-library/jest-dom in the test or configure the runner to load the project setup file. |
| Interaction method returns a promise | user-event actions are asynchronous. |
Make the test callback async and await each interaction. |
| Component crashes because context or routing is missing | The test rendered it without a provider expected by the application. | Wrap it with the needed provider, or use a shared render helper with RTL’s wrapper option. |
| Test depends on a real service or behaves inconsistently | The component’s API response is uncontrolled by the test. | Mock requests at the request boundary with MSW and define the response for the state the test covers. |
| Warning about deprecated React test utilities | The test uses deprecated react-dom/test-utils APIs. |
Use RTL’s render and user-focused DOM queries for component behavior tests. |
| Test passes only after adding a long timeout | The test may be waiting on an arbitrary duration rather than the UI condition, or async work is not settling. | Wait for the expected element or state. Check the request mock, promise, and component loading/error behavior. |
11. When browser screenshots add value
DOM assertions are a good fit for behavior such as “submitting this form shows a greeting.” When the question is instead “does this page render correctly at this URL, viewport, or state?”, a screenshot can help review the rendered page or support a visual comparison workflow. Screenshots complement behavior tests; they do not replace assertions about the interactions and outcomes a user needs.
Or skip the browser setup
ScreenshotNeo is a website screenshot API and MCP server for developers. One GET request returns a PNG, JPEG, WebP, or PDF. Here is the cURL request; see the ScreenshotNeo API documentation for the options and formats.
curl -G "https://api.screenshotneo.com/v1/shot" -d access_key=YOUR_API_KEY --data-urlencode url=https://stripe.com -o shot.webp
Cookie banners, newsletter popups, and chat widgets are removed before the shot. Bot checks, blank pages, and failed loads are never billed. An MCP server lets AI agents use tools to take screenshots. The free plan includes 1,000 screenshots a month with no card; paid plans start at $5 for 3,000.
Sign up free for 1,000 screenshots a month, with no card required.
12. Performance, reliability, and cost considerations
For component tests, keep dependencies deterministic: render only what the behavior needs, use controlled request responses, and wait for conditions instead of time delays. This makes failures easier to diagnose. Avoid relying on a live third-party service for routine component behavior tests.
Choose Jest, Vitest, or another compatible runner based on the project’s existing setup and confirm package compatibility against its installed versions. RTL itself is not the runner. The research behind this guide establishes no comparative speed figures, coverage thresholds, or productivity statistics, so none are implied here.
For screenshot capture, cost depends on the service and plan. ScreenshotNeo says only clean shots are billed: bot checks or CAPTCHAs, blank pages, timeouts, failed loads, and cache hits cost nothing, with response headers reporting the page verdict and billing status. Its published plans are Free: 1,000 shots/month; Starter: $5 for 3,000; Growth: $15 for 15,000; Pro: $39 for 60,000; Scale: $99 for 250,000; Business: $249 for 1,000,000. Yearly billing gives two months free, and every feature is on every plan. Review the current product documentation for request options and configuration before integrating it.
Frequently asked questions
Is React Testing Library a test runner?
No. It renders React into a DOM and provides testing utilities. Use it with a compatible runner such as Jest or Vitest.
Should I use a test ID for every element?
No. Prefer roles and accessible names for controls, and labels for fields. Use a test ID when a meaningful user-facing query is impractical.
Do I need to call act() in every test?
No. RTL wraps act() in most common APIs. Manual use is generally for advanced cases where the chosen setup requires it.
Can I test a component that makes an API request?
Yes. Mock the request boundary, for example with MSW, and assert the rendered loading, success, or error behavior.


