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 - Basic familiarity with Jest (RTL runs on top of it)
- You'll want
@testing-library/reactand@testing-library/user-eventinstalled
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. Fires realistic browser events (click, type, tab, paste) rather than synthetic React events. Prefer it over fireEvent for almost everything.
Query priority — RTL's recommended order: getByRole > getByLabelText > getByPlaceholderText > getByText > getByTestId. Prefer role-based queries because they reflect accessible semantics.
Setup from zero
Step 1 — Install and configure
npm install --save-dev @testing-library/react @testing-library/user-event @testing-library/jest-dom
Add to your Jest setup file:
// jest.setup.ts
import "@testing-library/jest-dom";
@testing-library/jest-dom gives you matchers like toBeInTheDocument(), toHaveValue(), and toBeDisabled(). Without it you're writing expect(el).not.toBe(null) everywhere, which is painful.
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. And — always set up userEvent with userEvent.setup(). The setup call creates a user session that tracks pointer position and event timing correctly. Calling userEvent.click directly (without setup) skips that, which can miss subtle bugs.
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 onSubmit = jest.fn();
const user = userEvent.setup();
render(<SignupForm onSubmit={onSubmit} />);
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(onSubmit).toHaveBeenCalledWith({
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 { PostList } from "./post-list";
test("loads and displays posts after fetch", async () => {
global.fetch = jest.fn().mockResolvedValue({
ok: true,
json: () => Promise.resolve([{ id: "1", title: "Hello world" }]),
} as unknown as Response);
render(<PostList />);
const post = await screen.findByText("Hello world");
expect(post).toBeInTheDocument();
});
findByText returns a promise and keeps retrying until the element appears or the timeout (1 000 ms by default) expires. Use findBy whenever the UI updates after an async operation — data fetching, timers, form submission callbacks. Using getByText here would throw immediately because the fetch hasn't resolved yet.
Little tip: if you're mocking fetch in many tests, consider switching to msw (Mock Service Worker). It intercepts actual network requests at the service-worker level so your tests run against a mock API server rather than a patched global — closer to production, much easier to maintain across a large test 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 queries are fine for edge cases but they're testing implementation details. A test that finds a button by role and accessible name also confirms the button is keyboard- and screen-reader-accessible. getByTestId just confirms the element exists. Prefer role.
userEvent over fireEvent — fireEvent.click dispatches a single synthetic event. userEvent.click simulates a full pointer interaction: hover, mousedown, focus, click, mouseup, blur. If your component breaks with real user events but passes with fireEvent, you've found a bug — not a test configuration issue.
One behaviour per test — each test should assert one observable behaviour. Not "the form works" but separately: "the form shows an error when email is missing" and "the form calls onSubmit when valid." Focused tests are easier to debug and their failure messages are actually informative.
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 — RTL wraps queries in act internally. If you're adding your own act calls around render and userEvent, you're probably fighting with RTL rather than working with it. Let RTL handle act, and await the async userEvent calls.
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 — timing issues. Async content that loads quickly in dev might not settle within the default findBy timeout on a slower CI runner. Increase it: await screen.findByText("...", {}, { timeout: 3000 }).
"Warning: An update to X inside a test was not wrapped in act" — there's an async state update RTL doesn't know to wait for. Switch from getBy to findBy around the update, or await all userEvent calls that trigger state changes.
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 observable behaviour per test
- [ ]
screen.debug()used as first debugging step when a query fails unexpectedly - [ ]
@testing-library/jest-domimported in Jest setup file for custom matchers
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. Jest is the test runner and assertion library. RTL is the set of utilities for rendering components and querying the DOM. They're separate tools and typically used together — RTL doesn't run tests, it gives you the queries and render utilities that your Jest tests call.
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
Related on Baseline
- [React forms without pain](/developers/react/react-forms-without-pain)
- [React hooks patterns in 2026](/developers/react/react-hooks-patterns-2026)
- [React performance checklist](/developers/react/react-performance-checklist)
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: prefer getByRole over everything else. It's the query that most closely mirrors how a user (and a screen reader) navigates the page, and it catches accessibility problems for free while you test functionality.