Testing #3: React Testing Library — See Like a User
If #2 was the place for a single pure function, this post is the place for components. React Testing Library (RTL) walks in.
Where this post sits in the Testing series:
- #1 Why Test
- #2 Vitest Setup and Your First Unit Test
- #3 React Testing Library — See Like a User ← this post
- #4 Async and Network Mocking — MSW
- #5 User Events and Form Tests
- #6 E2E and CI Integration with Playwright
This post covers RTL’s philosophy and only the most-used queries. User events and forms belong in #5, so here we’ll only touch a click or two.
RTL’s philosophy — “see like a user” #
Let me restate the direction established in #1. RTL’s core slogan is:
The more your tests resemble the way your software is used, the more confidence they can give you.
Every decision flows from that one sentence.
- Don’t peek at the component’s internal state. → Users can’t see it.
- Don’t find elements by
id/className. → Users don’t know about them. - Instead, find elements by role / label / visible text. → That’s how users perceive the screen.
This naturally produces accessibility (a11y)-friendly code. The way a screen reader sees the page and the way RTL sees the page are the same.
Setup — once #
Building on the vitest + react project from #2, install the additional packages.
pnpm add -D @testing-library/react @testing-library/dom @testing-library/jest-dom @testing-library/user-eventWhat each one does:
@testing-library/react—render, queries,cleanup, etc.@testing-library/dom— a dependency of the above (auto-installed, but listed for clarity).@testing-library/jest-dom— DOM-friendly matchers liketoBeInTheDocument,toBeVisible.@testing-library/user-event— user-event simulation likeuserEvent.click(),userEvent.type(). (The main subject of #5.)
Activate jest-dom in vitest.setup.ts:
import '@testing-library/jest-dom/vitest';That single line auto-adds matchers like toBeInTheDocument() to every test.
In vitest.config.ts, double-check that environment: 'jsdom' is on. Component tests need jsdom.
Your first component test — Greeting #
The simplest component.
type Props = { name?: string };
export function Greeting({ name = 'World' }: Props) {
return <h1>Hello, {name}!</h1>;
}The test:
import { render, screen } from '@testing-library/react';
import { Greeting } from './Greeting';
describe('Greeting', () => {
it('shows the given name', () => {
render(<Greeting name="Alice" />);
expect(screen.getByRole('heading')).toHaveTextContent('Hello, Alice!');
});
it('falls back to World when no name is given', () => {
render(<Greeting />);
expect(screen.getByRole('heading')).toHaveTextContent('Hello, World!');
});
});Three new friends showed up.
render— mounts the component into the DOM inside jsdom.screen— the object you call queries on against the mounted DOM. The convention is that all queries start fromscreen.(you used to pull them offrender’s return value, butscreenis cleaner).getByRole('heading')— finds an element on screen “with the role heading.”<h1>~<h6>automatically have that role.
toHaveTextContent is a jest-dom matcher. It checks the text node content.
You don’t need a separate cleanup after render. RTL auto-registers cleanup in vitest’s afterEach.
Queries — which one first? #
RTL has dozens of queries. You don’t need to memorize them; there’s a priority guide to follow.
1. Accessible to everyone (accessibility tree)
1.1 getByRole ← almost always first
1.2 getByLabelText ← form input
1.3 getByPlaceholderText
1.4 getByText
1.5 getByDisplayValue
2. Semantic queries
2.1 getByAltText ← img
2.2 getByTitle ← title attribute
3. test-id (last resort)
3.1 getByTestId ← data-testid attributeTry from the top down. Almost everything you need lands in 1.1 ~ 1.5.
getByRole — first stop
#
Finds elements by their WAI-ARIA role. HTML elements have implicit roles.
<button>Save</button> // role="button"
<a href="...">Home</a> // role="link"
<input type="checkbox" /> // role="checkbox"
<h1>Title</h1> // role="heading"
<nav>...</nav> // role="navigation"
<main>...</main> // role="main"
Finding them:
screen.getByRole('button', { name: 'Save' }); // a button whose text is 'Save'
screen.getByRole('link', { name: 'Home' });
screen.getByRole('checkbox', { name: 'Agree' });
screen.getByRole('heading', { level: 1 }); // h1 only
screen.getByRole('textbox', { name: 'Email' }); // input[type="text"|"email"]
The meaning of { name: '...' } is subtle. When several elements have the role button, it narrows down to “the one whose accessible name is ‘Save’.” The accessible name is:
- For a button or link, the text inside it.
- For an input, the text of its connected
<label>, or itsaria-label. - For an image, its
alt.
This is the same as the name a screen reader reads aloud. Write accessibility-friendly code and your tests get easier for free.
getByLabelText — form inputs
#
Almost always reach for this with form inputs.
<label htmlFor="email">Email</label>
<input id="email" type="email" />
// or
<label>
Email
<input type="email" />
</label>screen.getByLabelText('Email');It gives the same result as getByRole('textbox', { name: 'Email' }), but getByLabelText is more intent-revealing. The first reach for form tests.
getByText — just text
#
When you want to find by visible text on screen.
screen.getByText('Welcome'); // exact match
screen.getByText(/Wel/); // regex
screen.getByText((content) => content.startsWith('Wel')); // function
A caveat: getByText can also catch container elements (<div>). The nuance is a bit slippery — for <div>Welcome</div>, the div is matched; for <div>Wel<strong>come</strong></div>, the text is split across two children, so getByText('Welcome') doesn’t catch it.
data-testid — really the last resort
#
Only when none of the queries above can reach it, add a data-testid="..." attribute and use getByTestId.
<div data-testid="cart-total">$42.50</div>screen.getByTestId('cart-total');A testid is invisible to users — pointing in exactly the opposite direction of RTL’s philosophy. Hence the last resort. If 50% of your tests use testid, suspect the component’s accessibility itself.
getBy / queryBy / findBy — three places
#
The same query splits into three prefixes. This is the first sticking point.
| If not found | If multiple found | Async | |
|---|---|---|---|
getBy* | throws | throws | sync |
queryBy* | returns null | throws | sync |
findBy* | waits up to 1s, then throws | throws | Promise (async) |
There are also plural forms (*All*) — getAllByRole, findAllByText, etc.
What each is for:
expect(screen.getByRole('button', { name: 'Save' })).toBeInTheDocument();
// throws immediately if not found — intent is clear
expect(screen.queryByRole('alert')).not.toBeInTheDocument();
// using getByRole would throw on not-found and the assertion never runs
await screen.findByRole('alert');
// for toasts, error messages, etc. that show up asynchronously
Common slip-ups:
- Using
getByRoleto assert “this button is not on the screen” — when it’s missing, the test fails immediately with an error. You needqueryBy*. - Using a sync query for an async result — “at the moment of the lookup, it hasn’t rendered yet.” Use
findBy*orwaitFor(#4).
One click — the counter #
A second component.
import { useState } from 'react';
export function Counter() {
const [count, setCount] = useState(0);
return (
<div>
<p aria-live="polite">Current count: {count}</p>
<button onClick={() => setCount(count + 1)}>+1</button>
<button onClick={() => setCount(count - 1)}>-1</button>
<button onClick={() => setCount(0)}>reset</button>
</div>
);
}The test:
import { render, screen } from '@testing-library/react';
import { userEvent } from '@testing-library/user-event';
import { Counter } from './Counter';
describe('Counter', () => {
it('clicking +1 increases the count by 1', async () => {
const user = userEvent.setup();
render(<Counter />);
expect(screen.getByText('Current count: 0')).toBeInTheDocument();
await user.click(screen.getByRole('button', { name: '+1' }));
expect(screen.getByText('Current count: 1')).toBeInTheDocument();
await user.click(screen.getByRole('button', { name: '+1' }));
expect(screen.getByText('Current count: 2')).toBeInTheDocument();
});
it('clicking reset returns to 0', async () => {
const user = userEvent.setup();
render(<Counter />);
await user.click(screen.getByRole('button', { name: '+1' }));
await user.click(screen.getByRole('button', { name: '+1' }));
await user.click(screen.getByRole('button', { name: 'reset' }));
expect(screen.getByText('Current count: 0')).toBeInTheDocument();
});
});New pieces:
userEvent.setup()— creates auserEventinstance. The recommended pattern is to callsetup()once per test.await user.click(...)— every userEvent action is async. You need to await it for subsequent assertions to be accurate.
Why use userEvent — it’s closer to real user behavior than fireEvent. A click is actually a sequence of mousedown → mouseup → click, but fireEvent.click only fires the click. userEvent.click fires all three plus simulates focus changes. (Deeper dive in #5.)
aria-live and dynamic updates
#
The <p aria-live="polite"> in the Counter above is an interesting place. It’s an attribute that tells the screen reader, “let the user know when this text changes.” You can also find it with getByRole('status') (aria-live is part of the status role).
In places like this, RTL and accessibility meet naturally. Components that are easy to test usually have good accessibility.
within — searching inside a specific area
#
When you want to inspect just one part of a larger screen.
<>
<header>
<button>Log out</button>
</header>
<main>
<button>Save</button>
<button>Delete</button>
</main>
<footer>
<button>Help</button>
</footer>
</>On this screen, getByRole('button') would catch all four and throw. To look only inside the main area:
import { within } from '@testing-library/react';
const main = screen.getByRole('main');
const saveButton = within(main).getByRole('button', { name: 'Save' });within(element) builds a new query object scoped to that element.
jest-dom matchers — the ones you’ll use a lot #
The matchers @testing-library/jest-dom adds. DOM assertions become natural.
expect(el).toBeInTheDocument();
expect(el).toBeVisible();
expect(el).toBeDisabled();
expect(el).toBeEnabled();
expect(el).toBeChecked(); // checkbox
expect(el).toHaveValue('text'); // input
expect(el).toHaveTextContent('hello');
expect(el).toHaveClass('btn-primary');
expect(el).toHaveAttribute('href', '/about');
expect(el).toHaveFocus();
expect(el).toBeRequired();
expect(el).toHaveAccessibleName('Save');Two that get confused:
toBeInTheDocument()— is it attached to the DOM tree?toBeVisible()— and on top of that, is it notdisplay: noneand the like?
In places where the UI is conditionally hidden/shown, toBeVisible is more accurate.
Debugging — screen.debug()
#
When the test broke and you can’t tell why.
render(<MyComponent />);
screen.debug(); // prints the current DOM tree to the console
Or just one element:
const el = screen.getByRole('button');
screen.debug(el);Another powerful tool — screen.logTestingPlaygroundURL(). It prints a URL to the console; open it and Testing Playground visualizes the current DOM and recommends which query to use.
screen.logTestingPlaygroundURL();
// "Open this URL in your browser: https://testing-playground.com/#..."
When you’re stuck on how to write a query, this is the answer.
Common traps #
You see frequent act warnings — almost never with userEvent. They show up when you use fireEvent or call a state setter directly. Almost always the cause is forgetting to await an async change.
getByText doesn’t match — the text is likely split across multiple nodes. Use screen.debug() to inspect the real DOM. The function-form matcher or a regex is often the answer.
The same query matches multiple elements — switch to getAllBy* or narrow with the name option. Don’t escape into data-testid.
The test seems to behave differently from prod — usually because wrappers like <StrictMode>, <Suspense>, or a theme provider are missing. The standard move is a custom render:
// src/test/utils.tsx
import { render as rtlRender } from '@testing-library/react';
import { ThemeProvider } from '@/theme';
export function render(ui: React.ReactElement) {
return rtlRender(ui, {
wrapper: ({ children }) => <ThemeProvider>{children}</ThemeProvider>,
});
}
export * from '@testing-library/react';Then in tests, import { render, screen } from '@/test/utils'.
Wrap-up #
- RTL in one sentence — “the more your tests resemble the way your software is used, the more confidence they give you.”
- Mount components with
render; start queries fromscreen. - Query priority:
getByRole→getByLabelText→getByText→ … →getByTestId(last resort). - Separate the places for
getBy*(must exist, sync) /queryBy*(may not exist, sync) /findBy*(appears later, async). - Prefer
userEventoverfireEvent. CalluserEvent.setup()once per test. - jest-dom matchers (
toBeInTheDocument,toBeVisible,toHaveValue, …) are powerful once set up. - When stuck, reach for
screen.debug()andscreen.logTestingPlaygroundURL().
In the next post (#4 Async and Network Mocking), we’ll be where the component fetches data. The place for findBy* and waitFor, and the pattern of intercepting the network layer with MSW. The most-used place — and the one that traps you most often.