What you'll learn
By the end of this you'll understand the mental model behind React Testing Library — why it's different from Enzyme, which queries to use (and which to avoid), how to fire events with userEvent, and how to handle async UI correctly. You'll write tests that survive refactors because they test behaviour, not internals.
You'll also know what not to test, which is honestly half the skill. Tests that break every time you rename a state variable aren't telling you anything useful.
Who this is for
- You've heard you should be writing tests for your React components but you're not sure where to start
- You've used
.find("button")style selectors and wondered why your tests kept breaking - You want tests that give you confidence something works, not tests that just confirm the implementation
You can skip this if you're already comfortable with screen.getByRole, userEvent, and async queries. Jump to Common mistakes if you want a quick refresher on what to avoid.
What is React Testing Library?
React Testing Library (RTL) is a set of utilities for testing React components from the user's perspective. It renders a component into a real DOM (using jsdom) and gives you queries that find elements the way a user or screen reader would — by visible text, label, role, and placeholder.
Plain English: instead of testing the internal state of a component, you test what a user would see and interact with.
Simple idea: if you render a button with the text "Submit" and click it, RTL finds it by its accessible role and name — not by its CSS class or component name. That's what makes tests resilient to refactoring.
Prerequisites
- You can write React components and
useStatehooks - A test runner such as Jest or Vitest, configured with a DOM environment such as jsdom
@testing-library/react, its@testing-library/dompeer, and@testing-library/user-event
Key terms
render — RTL's function that mounts a component into a test DOM and returns query utilities.
screen — the object you query against. screen.getByRole, screen.findByText, etc. All queries run on the rendered output.
getBy — synchronous query. Throws immediately if the element isn't found. Use for elements you're confident are present.
queryBy — synchronous query. Returns null if not found instead of throwing. Use to assert an element is absent.
findBy — async query. Returns a promise and keeps retrying until the element appears or the timeout expires. Use after actions that trigger async state updates.
userEvent — the @testing-library/user-event library. It models composite interactions such as click, type, and tab in the test DOM. Prefer it for user behavior; keep fireEvent for low-level events that userEvent cannot express.
Query priority — prefer queries that match how people find the element: usually role and accessible name, then label text for form controls, then other visible semantics. Use data-testid when a user-facing query genuinely cannot express the target.
Setup from zero
Step 1 — Install and configure
npm install --save-dev @testing-library/react @testing-library/dom @testing-library/user-event @testing-library/jest-dom
Import the matcher adapter in your runner's setup file. Jest uses the base entry; Vitest uses @testing-library/jest-dom/vitest:
// Jest setup file
import "@testing-library/jest-dom";
@testing-library/jest-dom gives compatible runners matchers such as toBeInTheDocument(), toHaveValue(), and toBeDisabled(). React Testing Library supplies rendering and queries; it does not choose or run the test framework for you.
Step 2 — A first test
import { render, screen } from "@testing-library/react";
import { ContactForm } from "./contact-form";
test("shows the email input with a label", () => {
render(<ContactForm />);
expect(screen.getByLabelText("Email")).toBeInTheDocument();
});
getByLabelText finds the input connected to a label with the text "Email". This test works regardless of whether the input is a controlled <input>, a custom component, or what CSS classes it has. It breaks only if the label or the input itself disappears — which is exactly what you want a test to catch.
Step 3 — Testing a button click
import userEvent from "@testing-library/user-event";
test("submit button is accessible by role", async () => {
const user = userEvent.setup();
render(<ContactForm />);
const button = screen.getByRole("button", { name: /submit/i });
await user.click(button);
// assert what happened after the click
});
getByRole("button", { name: /submit/i }) finds a button whose accessible name matches the pattern. Prefer a userEvent.setup() session inside the test so consecutive actions share device state. Direct APIs exist for migration and simple cases, but the session form is the documented default.
The mental model
Test what the user can see and do, not how the component manages state internally. A test that checks wrapper.state().isSubmitting === true breaks every time you refactor state. A test that checks screen.getByText("Sending...") stays green as long as the UI still shows that text. This is the whole point. RTL makes you write the second kind of test by making the first kind harder.
Step-by-step
Testing form input and submission
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { SignupForm } from "./signup-form";
test("shows validation error when email is invalid", async () => {
const user = userEvent.setup();
render(<SignupForm />);
const emailInput = screen.getByLabelText(/email/i);
await user.type(emailInput, "not-an-email");
await user.tab(); // triggers blur
expect(screen.getByRole("alert")).toHaveTextContent(/valid email/i);
});
test("calls onSubmit with form values when valid", async () => {
const submissions: Array<{ name: string; email: string }> = [];
const user = userEvent.setup();
render(<SignupForm onSubmit={(values) => submissions.push(values)} />);
await user.type(screen.getByLabelText(/name/i), "Alex");
await user.type(screen.getByLabelText(/email/i), "alex@example.com");
await user.click(screen.getByRole("button", { name: /sign up/i }));
expect(submissions).toEqual([
{ name: "Alex", email: "alex@example.com" },
]);
});
No checking of state variables, no checking of React internals. The tests describe what a user does and what they see in response. That's the style to aim for.
Working examples
Async findBy for data that loads
import { render, screen } from "@testing-library/react";
import { http, HttpResponse } from "msw";
import { setupServer } from "msw/node";
import { PostList } from "./post-list";
const server = setupServer(
http.get("/api/posts", () =>
HttpResponse.json([{ id: "1", title: "Hello world" }])
)
);
beforeAll(() => server.listen({ onUnhandledRequest: "error" }));
afterEach(() => server.resetHandlers());
afterAll(() => server.close());
test("loads and displays posts after the request succeeds", async () => {
render(<PostList />);
const post = await screen.findByText("Hello world");
expect(post).toBeInTheDocument();
});
findByText returns a Promise and retries until the element appears or the query times out. Use a findBy query when an element should appear asynchronously. A synchronous getByText here can run before the request-driven state update finishes.
Little tip: MSW's setupServer intercepts requests in Node-based tests; it does not start an HTTP server or use a browser service worker there. Keeping the component's normal request code intact makes success, error, and edge-case handlers reusable across a larger suite.
Testing that something is NOT there
test("does not show error before the user touches the field", () => {
render(<SignupForm />);
expect(screen.queryByRole("alert")).not.toBeInTheDocument();
});
queryBy returns null instead of throwing. Use it when you want to assert absence. getBy would throw immediately if the element isn't found, which isn't what you want when you're asserting something isn't there.
Little tip: queryBy assertions are a common source of false confidence. queryByText("Error message") returns null if the element doesn't exist but also returns null if there's a typo in your selector string. Verify your selector actually matches when the element should be present before trusting an absence assertion.
Patterns / when to use
getByRole over getByTestId — data-testid is useful when no user-facing query fits, but role and accessible name exercise the semantics people use to find the control. That does not prove every keyboard interaction or accessibility requirement, so test those behaviors separately where they matter.
userEvent over fireEvent — fireEvent dispatches the event you name. userEvent models a user interaction as a sequence and performs checks such as whether the target can be interacted with. It still runs in a test environment and cannot create trusted browser events, so keep a few end-to-end tests for browser-only behavior.
One coherent behaviour per test — a test can need several assertions to prove one user-visible outcome. Separate unrelated branches such as invalid submission and successful submission so a failure points to one behavior rather than an entire page script.
Common mistakes
Testing internal state — reaching into the component's implementation: checking that a state variable is true, asserting on CSS classes, testing that a specific child component was rendered. These break on refactors even when the user-facing UI stays exactly the same.
Using getBy for async content
// Wrong: getByText throws immediately — the fetch hasn't resolved
render(<PostList />);
expect(screen.getByText("Hello world")).toBeInTheDocument();
// Right: findByText waits for the element to appear
expect(await screen.findByText("Hello world")).toBeInTheDocument();
This is probably the most common RTL mistake and the error message ("Unable to find element with text") is misleading because the element does appear — just not yet.
Not awaiting userEvent — userEvent.click and userEvent.type return promises. Forget the await and state updates from those events won't have settled before your assertions run. The test might pass sometimes and fail other times depending on timing — the worst kind of flaky test.
Manually wrapping everything in act — render, async utilities, and awaited userEvent interactions handle the common React update boundaries. If a warning remains, first await the observable UI result and check unresolved timers or Promises. Use manual act only when you directly drive an update outside those utilities.
Troubleshooting
"Unable to find an accessible element with the role" — the element exists in the DOM but either has no semantic role (a <div> has none) or the accessible name doesn't match. Check the role in the ARIA roles reference and run screen.debug() to print the rendered DOM so you can see what's actually there.
Test passes locally but fails in CI — check for an unhandled request, missing await, fake timer, shared state, or order dependency before increasing a timeout. Raise a query timeout only when the product behavior is intentionally slower and the test controls its dependencies.
"Warning: An update to X inside a test was not wrapped in act" — an update is still pending outside the interaction you awaited. Await every userEvent call, wait for the resulting UI with findBy or waitFor, and make sure request mocks and timers settle.
queryBy returns null for an element that's there — your selector is wrong. Run screen.debug() to print the DOM and compare the actual text, role, or label with what your query expects.
Checklist
- [ ]
userEvent.setup()called before each test, not the top-leveluserEventmethods directly - [ ] All
userEventactions awaited (user.click,user.type,user.tab) - [ ]
findByused for elements that appear after async operations - [ ]
queryBy(notgetBy) used to assert element absence - [ ]
getByRoleandgetByLabelTextpreferred overgetByTestId - [ ] No assertions on component state, CSS classes, or internal implementation details
- [ ] One coherent observable behaviour per test
- [ ]
screen.debug()used as first debugging step when a query fails unexpectedly - [ ] The correct
@testing-library/jest-domadapter imported for the selected runner
Practice task
Write three tests for the signup form from the React forms tutorial. Test 1: the form shows a validation error on the email field after blur with an invalid email. Test 2: the form shows no errors on initial render. Test 3: the form calls an onSubmit callback with the correct values after a valid submission. Use getByLabelText for inputs, getByRole for the button, and findBy if your submit handler is async. Don't check any state variables — only check what appears in the DOM.
FAQ
Should I test every component?
Test components with meaningful behaviour: forms, interactive widgets, components that fetch data, conditional rendering. Snapshot tests on every component add maintenance burden with little benefit. Test behaviour, not markup.
What's the difference between RTL and Cypress?
RTL runs in Node.js with jsdom — fast, no browser required. Cypress runs in a real browser against your actual running app. Use RTL for component-level unit and integration tests. Use Cypress (or Playwright) for end-to-end flows like "user signs up, logs in, creates a post."
Do I need to mock everything?
Mock the parts of the system your component doesn't own — HTTP requests, browser APIs, external services. Don't mock things your component imports directly (like utility functions) unless they have external dependencies of their own. The goal is to test your component in isolation, not in a hermetically sealed box where nothing real can happen.
Is React Testing Library the same as Jest?
No. React Testing Library renders components and supplies DOM queries. Jest, Vitest, or another framework discovers tests, runs them, and supplies assertions and mocks. Choose a DOM environment and the matching jest-dom adapter for that runner.
What to learn next
- React forms — the companion post builds the forms you'd test here, covering accessible labels, error states, and Zod validation
- msw (Mock Service Worker) — a cleaner way to mock API requests than patching
fetchglobally in every test file - Playwright — end-to-end testing in real browsers; the natural next step once component-level tests are solid
Takeaways
RTL's whole philosophy is that tests should break when behaviour breaks, not when implementation changes. Find elements by role and label, fire events with userEvent, wait for async updates with findBy. Those three habits cover the vast majority of what you'll need to test.
If you remember only one thing: start with getByRole and an accessible name when that matches how the element is found. It encourages useful semantics while you test behavior, but it complements rather than replaces accessibility and browser testing.