JavaScript and React course Β· Module 13: Testing React

React Testing Library - Mission Control Panel

11 min read
In this lesson9

Imagine a test that checks whether the isOpen variable in a component's state is true. You rename the variable to expanded, the app works exactly the same, and the test fails. Such a test guards implementation details, not what the pilot sees. After a few refactorings the team stops trusting the tests, because the red light goes on for no reason.

React Testing Library (RTL) is a library that changes how we test React components. Instead of testing internal implementation, RTL focuses on what the user sees and does - exactly like a mission control panel that shows the pilot only what matters.

The Core Principle of RTL

"The more your tests resemble the way your software is used, the more confidence they can give you." - Kent C. Dodds

Everything else follows from this principle: we look for elements the way a user does, by role, label and text, not by CSS classes or variable names.

Rendering Components

The render function is the foundation of RTL - it mounts a component into a jsdom document, a DOM implementation running in Node.js (this is not React's virtual DOM). The screen object gives you queries over the whole document:

1import { render, screen } from '@testing-library/react';
2import SpaceshipDashboard from './SpaceshipDashboard';
3
4test('renders spaceship dashboard', () => {
5  render(<SpaceshipDashboard pilotName="Nova" />);
6  // Component is now available for testing
7  expect(screen.getByText('Welcome, Nova!')).toBeInTheDocument();
8});

After render, the component is in the document, and screen.getByText finds it the way a user would read it. You don't have to clean up after yourself: RTL automatically calls cleanup after each test, unmounting the components, whenever the test runner provides a global afterEach function. Jest always does, Vitest does once you enable the globals: true option. As a result, every test starts with an empty document and never sees the leftovers of the previous ones.

Queries - Finding Elements

RTL offers three query families, each for a different purpose. They differ in how they behave when the element is missing.

getBy - Finds and Requires

Use getBy when the element must exist. The query throws if it is missing, and also when more than one element matches:

1// Throws error if element does NOT exist
2const heading = screen.getByText('Mission Control');
3const input = screen.getByRole('textbox');
4const img = screen.getByAltText('spaceship');

For multiple elements there is the getAllBy variant, which returns an array. You will look at it more closely, together with queryAllBy and within, later in this lesson.

queryBy - Finds but Doesn't Require

queryBy is handy for checking that something is not on the screen:

1// Returns null if element does NOT exist (no error)
2const error = screen.queryByText('Error occurred');
3expect(error).not.toBeInTheDocument();

If you used getByText here, the test would fail on the lookup itself, before reaching the assertion.

findBy - Finds Asynchronously

findBy waits for the element to appear, so it returns a Promise and needs await:

1// Waits for element to appear (returns Promise)
2const data = await screen.findByText('Data loaded');
3expect(data).toBeInTheDocument();

Under the hood it combines getBy with waitFor. By default it waits up to 1000 ms and then reports an error.

Types of Selectors

Each query family has variants. A query's name is the family plus the search method, for example getByRole or findByText.

ByRole - Best Practice!

An ARIA role is what an element is for a screen reader: a button, a heading, a text field. The name option matches the accessible name, usually the text or the label:

1// Searches by ARIA role - the best approach!
2screen.getByRole('button', { name: 'Launch' });
3screen.getByRole('heading', { level: 1 });
4screen.getByRole('textbox', { name: /pilot name/i });
5screen.getByRole('checkbox', { checked: true });
6screen.getByRole('link', { name: 'Dashboard' });

If getByRole cannot find a button, it often signals an accessibility problem in the component itself, not in the test. To use this query with confidence, you need to know two things: where an element gets its role, and where it gets its name.

Roles Built into Tags

The test has to find the mission list, but the component code has no role attribute at all? It doesn't need one. Most HTML tags have a default (implicit) role that the browser and RTL read on their own:

TagRole
<button>button
<a href="...">link
<h1> to <h6>heading (the level option is the heading number)
<input type="text">, <textarea>textbox
<input type="checkbox">checkbox
<select>combobox
<ul> or <ol> and <li>list and listitem
<table>, <tr>, <th>, <td>table, row, columnheader, cell

Two traps are worth remembering. A link without an href attribute has no link role, and a <select> dropdown is a combobox for a screen reader, so you find it with getByRole('combobox'), not "select".

Roles Given with the role Attribute

You usually build a fuel gauge, a warning message or tabs from div and p elements, which have no role of their own. Then you give the role explicitly with the role attribute and describe the state with aria-* attributes:

1function ShieldMeter({ level }) {
2  return (
3    <div>
4      <div
5        role="progressbar"
6        aria-label="Shield level"
7        aria-valuenow={level}
8        aria-valuemin={0}
9        aria-valuemax={100}
10      />
11      {level < 20 && <p role="alert">Shields critical!</p>}
12    </div>
13  );
14}

role="progressbar" turns a plain div into a progress indicator: aria-valuenow, aria-valuemin and aria-valuemax give its value and range, and aria-label gives it a name. role="alert" marks an urgent message that a screen reader announces immediately. Calmer information, such as "Scan complete", gets role="status", and tabs get three roles at once: tablist on the container, tab on each button and tabpanel on the content.

The test checks the gauge and the warning the way a screen reader would read them:

1test('warns when shields are critical', () => {
2  render(<ShieldMeter level={15} />);
3
4  const meter = screen.getByRole('progressbar', { name: 'Shield level' });
5  expect(meter).toHaveAttribute('aria-valuenow', '15');
6  expect(screen.getByRole('alert')).toHaveTextContent('Shields critical!');
7});

Notice that we don't look for the message with the name option. The alert and status roles don't take their name from the content, so the toHaveTextContent matcher checks the text, and toHaveAttribute compares an attribute's value. Both come from jest-dom, which you'll meet in a moment.

Where an Element Gets Its Name

The name option compares the element's accessible name, that is what a screen reader announces together with the role. The name usually comes from one of three sources:

1// 1. From the element's content: buttons, links, headings
2// <button>Launch</button>
3screen.getByRole('button', { name: 'Launch' });
4
5// 2. From aria-label, when the element has no readable text
6// <button aria-label="Close panel">Γ—</button>
7screen.getByRole('button', { name: 'Close panel' });
8
9// 3. From a label linked with htmlFor and id...
10// <label htmlFor="pilot">Pilot name</label>
11// <input id="pilot" type="text" />
12screen.getByRole('textbox', { name: 'Pilot name' });
13
14// ...or from a label that wraps the field
15// <label><input type="checkbox" /> Autopilot</label>
16screen.getByRole('checkbox', { name: 'Autopilot' });

For RTL, a field without a label has an empty name, even if it has a placeholder, so a query with the name option will not find it. That's good news: the test points out the missing label right away, and a placeholder disappears once you type, so it cannot replace a label.

Filtering by State: checked, pressed, selected

When the screen has several elements with the same role, you can pick the one in a particular state:

1// A checked checkbox (or radio)
2screen.getByRole('checkbox', { name: 'Autopilot', checked: true });
3
4// A pressed toggle: <button aria-pressed="true">Orbit</button>
5screen.getByRole('button', { pressed: true });
6
7// The selected tab or the selected option of a dropdown
8screen.getByRole('tab', { selected: true });
9screen.getByRole('option', { name: 'Mars', selected: true });

checked reads the state of a checkbox, pressed reads the aria-pressed attribute of buttons that work like a switch, and selected reads the aria-selected attribute of tabs or the selection of an option in a <select>. When a query with such a filter finds nothing, you know right away that the component shows a different state than the test assumed.

ByText - Searches by Text

Text works well for non-interactive elements, such as messages and paragraphs:

1screen.getByText('Mission Status: Active');
2screen.getByText(/mission/i); // regex, case-insensitive

A plain string must match in full, while a regular expression lets you search for a fragment regardless of case.

ByLabelText - Searches by Form Label

Form fields are best found by their label, just like a user does:

1// <label htmlFor="speed">Speed</label>
2// <input id="speed" />
3screen.getByLabelText('Speed');

The query works only when the label is correctly linked to the field, so the test guards accessibility along the way.

ByPlaceholderText - Searches by Placeholder

A placeholder is the hint inside an empty field:

1screen.getByPlaceholderText('Enter coordinates');

Treat it as a fallback, because the placeholder disappears once text is typed and does not replace a label.

ByTestId - Last Resort

The data-testid attribute is a marker visible only to tests:

1// <div data-testid="fuel-gauge">...</div>
2screen.getByTestId('fuel-gauge');

The user never sees it, so a test based on it tells you the least about the real experience.

Selector Priority

RTL recommends the following priority (from best):

  1. getByRole - accessibility, how user sees the element
  2. getByLabelText - forms
  3. getByPlaceholderText - when there's no label
  4. getByText - non-interactive elements
  5. getByDisplayValue - current input value
  6. getByAltText - images
  7. getByTitle - title attribute
  8. getByTestId - last resort, when nothing else fits

Many Elements and Queries Inside an Element

A mission list has several cards, a table has several rows, and notifications often have buttons with the same name. getBy would throw here, because more than one element matches. For such cases every query family has an All variant, and the within function narrows the search to the inside of one element. Let's assume CrewTable shows a table with a header row and one row per crew member:

1import { render, screen, within } from '@testing-library/react';
2
3test('lists the crew in a table', () => {
4  const crew = [
5    { name: 'Nova', role: 'Pilot' },
6    { name: 'Astro', role: 'Engineer' },
7  ];
8  render(<CrewTable crew={crew} />);
9
10  // getAllBy returns an array and throws when nothing matches
11  const rows = screen.getAllByRole('row');
12  expect(rows).toHaveLength(3); // the header and two crew members
13
14  // within limits the queries to the inside of one row
15  const cells = within(rows[1]).getAllByRole('cell');
16  expect(cells[0]).toHaveTextContent('Nova');
17
18  // queryAllBy returns an empty array instead of throwing
19  expect(screen.queryAllByRole('alert')).toHaveLength(0);
20});

getAllBy (and its asynchronous counterpart findAllBy) throws when it finds no element at all, while queryAllBy returns an empty array in that case, which makes it the right tool for checking that elements disappeared. within(element) gives you the same queries as screen, but searches only inside the given element. The same way you find the close button in one particular notification: within(screen.getByRole('alert')).getByRole('button', { name: 'Close' }). You import within from @testing-library/react, together with render and screen.

jest-dom Matchers

The @testing-library/jest-dom library adds special matchers. You enable it once, by importing @testing-library/jest-dom in the jest.setup.js file that you pointed to with the setupFilesAfterEnv option in the first lesson:

1// Visibility
2expect(element).toBeVisible();
3expect(element).toBeInTheDocument();
4
5// Attributes and state
6expect(button).toBeDisabled();
7expect(input).toBeRequired();
8expect(input).toHaveValue('Apollo');
9expect(checkbox).toBeChecked();
10expect(link).toHaveAttribute('href', '/dashboard');
11
12// CSS classes
13expect(element).toHaveClass('active');
14
15// Text
16expect(element).toHaveTextContent('Mission');
17
18// Styles
19expect(element).toHaveStyle({ color: 'rgb(0, 128, 0)' });

toBeInTheDocument only checks presence in the document, while toBeVisible also checks that the element is not hidden, for example by display: none. toHaveStyle compares the style computed by jsdom, which returns colors in the rgb(...) notation, so you give a color as rgb(0, 128, 0) or #008000: the bare name green will not pass.

Rendering with Context

Components often need providers (Router, Theme, Store). Instead of repeating them in every test, we create our own render function with the wrapper option:

1function renderWithProviders(ui, options = {}) {
2  function Wrapper({ children }) {
3    return (
4      <ThemeProvider theme="dark">
5        <MissionProvider>
6          {children}
7        </MissionProvider>
8      </ThemeProvider>
9    );
10  }
11  return render(ui, { wrapper: Wrapper, ...options });
12}
13
14// Usage
15test('renders themed dashboard', () => {
16  renderWithProviders(<Dashboard />);
17  expect(screen.getByText('Dark Mode')).toBeInTheDocument();
18});

renderWithProviders takes the same arguments as render, so tests look almost identical, and the setup lives in one place.

Debug - When Tests Fail

When a test fails and you don't know why, use screen.debug() to inspect the current DOM state:

1test('shows mission status', () => {
2  render(<MissionPanel />);
3
4  // Displays current DOM state in the console
5  screen.debug();
6
7  // You can also debug a specific element
8  const panel = screen.getByRole('main');
9  screen.debug(panel);
10});

screen.debug() will display the HTML of the entire rendered component, which helps understand why a selector can't find an element. It's like a diagnostic system on the spaceship - when something doesn't work, you check the logs first!

My advice: start with getByRole and go lower in the priority list only when you have to. In the next lesson we will add simulated clicks and typing.

React Testing Library is the foundation of modern React component testing. Instead of testing implementation details, you test what really matters - the user experience.

Code for this lesson: App.jsx
1import React, { useState } from 'react';
2
3const MISSIONS = [
4  { id: 1, name: 'Apollo 11', status: 'completed', crew: 3 },
5  { id: 2, name: 'Artemis I', status: 'active', crew: 0 },
6  { id: 3, name: 'Mars Explorer', status: 'planned', crew: 6 },
7  { id: 4, name: 'Jupiter Probe', status: 'active', crew: 4 },
8];
9
10const STATUS_LABELS = {
11  completed: 'completed',
12  active: 'active',
13  planned: 'planned',
14};
15
16// The RTL query that finds the element above in its current state
17function Query({ code }) {
18  return <code className="query">{code}</code>;
19}
20
21function MissionCard({ mission, selected, onSelect }) {
22  return (
23    <li className={'mission-card status-' + mission.status}>
24      <h3>{mission.name}</h3>
25      <p>Status: <span className="badge">{STATUS_LABELS[mission.status]}</span></p>
26      <p>Crew: {mission.crew}</p>
27      <button
28        aria-label={'Select mission ' + mission.name}
29        aria-pressed={selected}
30        onClick={onSelect}
31      >
32        Select
33      </button>
34    </li>
35  );
36}
37
38function MissionList() {
39  const [query, setQuery] = useState('');
40  const [onlyActive, setOnlyActive] = useState(false);
41  const [selected, setSelected] = useState(null);
42
43  const visible = MISSIONS.filter(
44    (m) =>
45      m.name.toLowerCase().includes(query.toLowerCase()) &&
46      (!onlyActive || m.status === 'active')
47  );
48  const statusText = selected ? 'Selected mission: ' + selected : 'No mission selected';
49  const pressedVisible = visible.some((m) => m.name === selected);
50
51  return (
52    <div className="mission-list">
53      <div className="field">
54        <label htmlFor="search">Search missions</label>
55        <input
56          id="search"
57          type="text"
58          value={query}
59          placeholder="Enter mission name..."
60          onChange={(e) => setQuery(e.target.value)}
61        />
62        <Query code="screen.getByRole('textbox', { name: 'Search missions' })" />
63      </div>
64
65      <div className="field">
66        <label className="checkbox">
67          <input
68            type="checkbox"
69            checked={onlyActive}
70            onChange={(e) => setOnlyActive(e.target.checked)}
71          />
72          Active only
73        </label>
74        <Query code={"screen.getByRole('checkbox', { name: 'Active only', checked: " + onlyActive + " })"} />
75      </div>
76
77      <p role="status" className="selected-info">{statusText}</p>
78      <Query code={"expect(screen.getByRole('status')).toHaveTextContent('" + statusText + "')"} />
79      <Query
80        code={pressedVisible
81          ? "screen.getByRole('button', { name: 'Select mission " + selected + "', pressed: true })"
82          : "expect(screen.queryByRole('button', { pressed: true })).toBeNull()"}
83      />
84
85      {visible.length > 0 ? (
86        <>
87          <Query code={"expect(screen.getAllByRole('listitem')).toHaveLength(" + visible.length + ")"} />
88          <ul className="cards">
89            {visible.map((m) => (
90              <MissionCard
91                key={m.id}
92                mission={m}
93                selected={selected === m.name}
94                onSelect={() => setSelected(m.name)}
95              />
96            ))}
97          </ul>
98        </>
99      ) : (
100        <>
101          <p className="no-results">No missions found</p>
102          <Query code="expect(screen.queryAllByRole('listitem')).toHaveLength(0)" />
103        </>
104      )}
105    </div>
106  );
107}
108
109export default function App() {
110  return (
111    <div className="app">
112      <h1>React Testing Library - Control Panel</h1>
113      <p className="note">
114        The preview does not run Jest. Under the elements you can see the RTL
115        queries that find them in their current state: type a name, tick the box
116        or select a mission and watch the checked and pressed options change.
117      </p>
118      <MissionList />
119    </div>
120  );
121}

Spotted a mistake in this lesson?

Check yourself

Answer the questions from this lesson. Pick an answer to see right away whether it is correct.

  1. 1. What does the cleanup() function in React Testing Library do, and when is it called automatically?

  2. 2. What is the key difference between getBy and queryBy in React Testing Library?

These are 2 of 4 questions for this lesson. Solve the rest in the game.

Hands-on tasks in the game

  • Code editor

    Finish the fuel gauge so that the test finds it the way a screen reader user does. ___BLANK1___: isLow is true when the fuel level is lower than 20. ___BLANK2___: the bar has the progressbar role, so the test finds it with getByRole('progressbar', { name: 'Fuel level' }) (aria-label gives the name and aria-valuenow the level). ___BLANK3___: the low fuel warning has the alert role, which a screen reader announces at once. ___BLANK4___: the Refuel button adds 25 percentage points, but the level must not go above 100.

  • Horizontal ordering

    Arrange the elements of the screen.getByRole query with a name option in the correct order:

  • Code editor

    Finish the CrewForm crew recruitment form so that it can be tested with React Testing Library. ___BLANK1___: the Crew member name label points with htmlFor at the id of the text field (crew-name), so the test finds it with getByLabelText('Crew member name'). ___BLANK2___: the name error appears when the name, after trimming spaces from the start and the end, has fewer than 2 characters. ___BLANK3___: the role error appears when no role was chosen (the empty value of the list). The errors have role="alert", and with valid data the form calls onSubmit({ name, role }) with the trimmed name and clears the fields.

  • Vertical ordering

    Arrange the steps for rendering and testing a component in RTL in the correct order:

  • Code editor

    Finish two components of the control panel. ToggleSwitch is a checkbox inside a label, so the test finds it with getByRole('checkbox', { name: 'Shields' }). ___BLANK1___: the checkbox is controlled, its checked state comes from the on state. ___BLANK2___: after a click the state takes the new checked value from the event object (the checked field of event.target), and the status next to it changes to on or off. NavigationList shows the travel destination buttons. ___BLANK3___: aria-pressed of a button is true only for the selected destination and false for the others, so the test finds the selected destination with getByRole('button', { pressed: true }).

Useful articles