π§ͺ React Testing Library Basics
A test that breaks every time you rename a variable is worse than no test at all. React Testing Library flips the script: instead of poking at a component's internals, you interact with it the way a real user would β find a button by its label, click it, and check what changed on screen. In this lesson you'll write your first component tests using that user-first philosophy.
Week 5 · Day 5 (Friday: Testing React Components) · Lecture 1
π― Learning Objectives
By the end of this lesson, you will be able to:
- Explain why React Testing Library tests behavior instead of implementation details
- Render a component into a virtual DOM with
render()and inspect it withscreen - Choose the right query and query variant (
getBy,queryBy,findBy) for the job - Follow the accessible query priority so your tests double as accessibility checks
- Simulate realistic interactions with
userEventv14 andasync/await - Write clear assertions with jest-dom matchers like
toBeInTheDocument()
Estimated Time: 70 minutes
Practice: Test a greeting component and a login form the way a user would use them.
In This Lesson
Why Test Behavior, Not Internals
Imagine a quality inspector at a car factory. A bad inspector unscrews every panel to verify each bolt was torqued to spec β slow, and if the design changes, every checklist is thrown out. A great inspector drives the car: do the doors open, does the radio play, does it stop when you brake? React Testing Library (RTL) is the second inspector. It renders your component and lets you probe it from the outside β the same surface your users touch.
The alternative β reaching into a component's state, props, or private methods β is called testing implementation details. Those tests break the instant you refactor, even when the app still works perfectly. RTL's guiding principle, from its author Kent C. Dodds, is worth memorizing:
"The more your tests resemble the way your software is used, the more confidence they can give you."
RTL is not a test runner. You still need a runner like Jest or Vitest to execute tests and provide describe, test, and expect. RTL supplies the tools to render components and query the resulting DOM.
render & screen: Your First Test
Every RTL test starts by mounting a component into a virtual DOM (provided by jsdom). The render() function does that, and the screen object gives you a set of queries that search the whole rendered document. Here's a tiny component and its first test.
// Greeting.jsx β the component under test
export function Greeting({ name = 'Stranger' }) {
return (
<section>
<h1>Hello, {name}!</h1>
<p>Welcome to our application.</p>
</section>
);
}
// Greeting.test.jsx
import { render, screen } from '@testing-library/react';
import { Greeting } from './Greeting';
describe('Greeting', () => {
test('renders the default greeting', () => {
render(<Greeting />);
// screen queries search the entire rendered output
const heading = screen.getByRole('heading', { name: 'Hello, Stranger!' });
expect(heading).toBeInTheDocument();
});
test('renders a custom name', () => {
render(<Greeting name="Ada" />);
expect(
screen.getByRole('heading', { name: 'Hello, Ada!' })
).toBeInTheDocument();
});
});
π Always use screen
Older tutorials destructured queries from render: const { getByText } = render(...). The modern approach is to import screen and call screen.getByRole(...). It reads clearly, never gets out of sync with what's on the page, and you don't have to update a destructuring list every time you need a new query. RTL also auto-cleans the DOM between tests, so each render starts fresh.
The RTL Cycle
Almost every RTL test follows the same four-beat rhythm. Internalize it and writing tests becomes muscle memory: render β query β interact β assert.
mount the component] --> B[Query
find elements a user sees] B --> C[Interact
type, click, select] C --> D[Assert
check the visible result] D --> B
Simple display components skip the "interact" step β render, query, assert, done. Interactive components loop through query β interact β assert as many times as needed to walk through a scenario. Everything else in this lesson is just filling in the details of each beat.
Queries & Query Priority
A query is how you find an element. RTL deliberately steers you toward queries that mirror how users and assistive technology perceive the page. Following the recommended priority means your tests also verify your UI is accessible β a two-for-one deal.
| Priority | Query | Finds elements by |
|---|---|---|
| 1 | getByRole | ARIA role + accessible name (button, heading, textboxβ¦) |
| 2 | getByLabelText | The <label> tied to a form field |
| 3 | getByPlaceholderText | An input's placeholder |
| 4 | getByText | Visible text content |
| 5 | getByDisplayValue | The current value of a form field |
| 6 | getByAltText | An image's alt attribute |
| 7 | getByTitle | The title attribute |
| 8 | getByTestId | A data-testid β last resort only |
function QueryExamples() {
return (
<div>
<h1>Query Examples</h1>
<button>Save</button>
<label htmlFor="username">Username</label>
<input id="username" />
<input type="text" placeholder="Searchβ¦" />
<img src="/logo.png" alt="Company logo" />
</div>
);
}
test('demonstrates the query priority in action', () => {
render(<QueryExamples />);
// 1. By role β the gold standard
screen.getByRole('heading', { name: 'Query Examples' });
screen.getByRole('button', { name: 'Save' });
// 2. By label text β for form controls
screen.getByLabelText('Username');
// 3. By placeholder β when there is no label (prefer a label!)
screen.getByPlaceholderText('Searchβ¦');
// 6. By alt text β for images
screen.getByAltText('Company logo');
});
β
Why getByRole wins
Screen readers navigate by role. If getByRole('button', { name: 'Save' }) can't find your button, neither can a screen-reader user. Reaching for getByRole first nudges you to write semantic, labeled HTML. Save getByTestId for the rare element with no accessible handle at all β a decorative wrapper you truly must target.
getBy vs queryBy vs findBy
Each query name comes in three variants. Picking the right one is one of the most common early stumbling blocks, so let's make the rule crisp: choose based on whether the element should exist now, should be absent, or will appear later.
| Variant | If not found | Async? | Use when⦠|
|---|---|---|---|
getBy⦠| throws an error | no | the element should already be on screen |
queryBy⦠| returns null | no | you're asserting an element is absent |
findBy⦠| rejects after ~1s | yes (Promise) | the element appears after an async update |
// getBy β must exist right now
expect(screen.getByRole('button', { name: 'Login' })).toBeInTheDocument();
// queryBy β proves something is NOT there (getBy would throw)
expect(screen.queryByRole('alert')).not.toBeInTheDocument();
// findBy β waits for an element that shows up later (note: await)
const banner = await screen.findByText('Saved!');
expect(banner).toBeInTheDocument();
β οΈ The classic mistake
Never use getBy to check for absence β it throws before your assertion runs, and never use queryBy to grab something you intend to use, because a stray null gives a confusing error later. Memorize: get to grab, query to prove-absent, find to wait.
Simulating Users with userEvent
To test interactive components you need to drive them. RTL's companion library @testing-library/user-event (v14) simulates real interactions far more faithfully than the older fireEvent: typing dispatches keydown/keypress/input/keyup for each character, clicking includes focus and pointer events, and so on.
Two rules for v14: call userEvent.setup() once at the start of the test, and await every interaction β they are all async.
import { useState } from 'react';
function LoginForm({ onSubmit }) {
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
const [error, setError] = useState('');
const handleSubmit = (e) => {
e.preventDefault();
if (!email || !password) {
setError('All fields are required');
return;
}
onSubmit({ email, password });
};
return (
<form onSubmit={handleSubmit}>
<h2>Login</h2>
{error && <div role="alert">{error}</div>}
<label htmlFor="email">Email</label>
<input id="email" type="email"
value={email} onChange={(e) => setEmail(e.target.value)} />
<label htmlFor="password">Password</label>
<input id="password" type="password"
value={password} onChange={(e) => setPassword(e.target.value)} />
<button type="submit">Login</button>
</form>
);
}
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
describe('LoginForm', () => {
test('renders all fields', () => {
render(<LoginForm onSubmit={() => {}} />);
expect(screen.getByLabelText('Email')).toBeInTheDocument();
expect(screen.getByLabelText('Password')).toBeInTheDocument();
expect(screen.getByRole('button', { name: 'Login' })).toBeInTheDocument();
});
test('shows an error when submitting an empty form', async () => {
const onSubmit = jest.fn();
const user = userEvent.setup(); // 1. set up once
render(<LoginForm onSubmit={onSubmit} />);
await user.click(screen.getByRole('button', { name: 'Login' })); // 2. await
expect(screen.getByRole('alert')).toHaveTextContent('All fields are required');
expect(onSubmit).not.toHaveBeenCalled(); // behavior, not internals
});
test('submits the entered credentials', async () => {
const onSubmit = jest.fn();
const user = userEvent.setup();
render(<LoginForm onSubmit={onSubmit} />);
await user.type(screen.getByLabelText('Email'), 'ada@example.com');
await user.type(screen.getByLabelText('Password'), 's3cret!');
await user.click(screen.getByRole('button', { name: 'Login' }));
expect(onSubmit).toHaveBeenCalledWith({
email: 'ada@example.com',
password: 's3cret!',
});
});
});
Notice how these tests read almost like a QA script: "find the email box, type an address, click Login, expect the form to submit." That readability is the payoff of the user-first approach. The most common actions you'll reach for:
| Method | What it does |
|---|---|
await user.click(el) | A full click (pointer down/up, focus) |
await user.type(el, 'text') | Types character by character |
await user.clear(el) | Clears an input |
await user.selectOptions(el, 'us') | Picks a <select> option |
await user.keyboard('{Enter}') | Presses keys |
await user.tab() | Moves focus to the next element |
Assertions with jest-dom
The @testing-library/jest-dom package adds DOM-aware matchers to expect so your assertions describe intent clearly. Instead of expect(el).not.toBeNull() you write expect(el).toBeInTheDocument(). Register it once in your test setup file and every test can use it.
// setupTests.js β imported once (via Jest's setupFilesAfterEach)
import '@testing-library/jest-dom';
// A tour of the matchers you'll use most
expect(el).toBeInTheDocument(); // element is in the DOM
expect(el).toHaveTextContent('Welcome'); // contains this text
expect(input).toHaveValue('ada@example.com'); // form field value
expect(checkbox).toBeChecked(); // checkbox / radio state
expect(button).toBeDisabled(); // disabled attribute
expect(el).toBeVisible(); // not hidden by CSS
expect(link).toHaveAttribute('href', '/home');
Output β a failing assertion is readable
expect(element).toBeInTheDocument()
received value must be an HTMLElement or an SVGElement.
Received has value: null
// β your query found nothing; check the role/name
Practice & Quiz
ποΈ Exercise 1: Test a counter
Goal: Given the component below, write two tests β one that it starts at zero, and one that clicking "Increment" makes it show Count: 1. Use getByRole and userEvent.
function Counter() {
const [count, setCount] = useState(0);
return (
<div>
<p>Count: {count}</p>
<button onClick={() => setCount((c) => c + 1)}>Increment</button>
</div>
);
}
π‘ Hint
Render the component, then screen.getByText('Count: 0'). For the click test, set up userEvent, await user.click(screen.getByRole('button', { name: 'Increment' })), then assert on Count: 1.
β Solution
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
test('starts at zero', () => {
render(<Counter />);
expect(screen.getByText('Count: 0')).toBeInTheDocument();
});
test('increments on click', async () => {
const user = userEvent.setup();
render(<Counter />);
await user.click(screen.getByRole('button', { name: 'Increment' }));
expect(screen.getByText('Count: 1')).toBeInTheDocument();
});
ποΈ Exercise 2: Toggle visibility
Goal: A <Disclosure> shows a "Show details" button; clicking it reveals a paragraph with role region. Write a test proving the region is absent initially and present after the click. Which query variant proves absence?
β Solution
test('reveals details on demand', async () => {
const user = userEvent.setup();
render(<Disclosure />);
// queryBy proves it is NOT there yet
expect(screen.queryByRole('region')).not.toBeInTheDocument();
await user.click(screen.getByRole('button', { name: 'Show details' }));
// now it exists β getBy is fine
expect(screen.getByRole('region')).toBeInTheDocument();
});
π― Quick Quiz
Question 1: You want to assert that an error message is not on the page. Which query variant should you use?
Question 2: Which query should you reach for first according to RTL's priority?
Question 3: With userEvent v14, what must you do before each interaction like type or click?
Best Practices & Pitfalls
β Do
- Use
screen.getByRole(...)with an accessiblenameas your default query - Prefer
userEventoverfireEvent, andawaitevery interaction - Assert on what the user sees β visible text, enabled/disabled state, form values
- Give inputs real
<label>s sogetByLabelTextand screen readers both work
β Don't
- Reach into component state, spy on
useState, or test private methods - Sprinkle
data-testideverywhere as a shortcut around accessible queries - Use
getByto check for absence β that throws; usequeryBy - Forget to
awaitafindByor auser.*call
β οΈ fireEvent still has a niche
// userEvent can't simulate a raw scroll β fireEvent can
import { fireEvent } from '@testing-library/react';
fireEvent.scroll(container, { target: { scrollTop: 500 } });
For 95% of interactions use userEvent. Fall back to fireEvent only for low-level DOM events userEvent doesn't model (scroll, some media events).
Summary
π Key Takeaways
- RTL tests behavior, not implementation β the more they resemble real usage, the more confidence they give
- The cycle is render β query β interact β assert
- Follow the query priority:
getByRolefirst,getByTestIdlast getByto grab,queryByto prove absence,findByto await- Use
userEvent.setup()andawaitevery interaction for realistic input - jest-dom matchers like
toBeInTheDocument()make assertions read like intent
π Additional Resources
- Testing Library β React Testing Library intro
- Testing Library β Query priority guide
- Testing Library β user-event v14
- jest-dom β custom DOM matchers
π What's Next?
You can now render, query, and interact with components. But real apps fetch data and use timers, which happen over time. The next lesson tackles that head-on: Testing Hooks and Async Code β findBy, waitFor, renderHook, and fake timers.
π First tests, done right!
You're testing the way users actually use your app β the kind of test that keeps passing while your code evolves.