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:

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.

install
pnpm add -D @testing-library/react @testing-library/dom @testing-library/jest-dom @testing-library/user-event

What each one does:

  • @testing-library/reactrender, queries, cleanup, etc.
  • @testing-library/dom — a dependency of the above (auto-installed, but listed for clarity).
  • @testing-library/jest-dom — DOM-friendly matchers like toBeInTheDocument, toBeVisible.
  • @testing-library/user-event — user-event simulation like userEvent.click(), userEvent.type(). (The main subject of #5.)

Activate jest-dom in vitest.setup.ts:

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.

src/components/Greeting.tsx
type Props = { name?: string };

export function Greeting({ name = 'World' }: Props) {
  return <h1>Hello, {name}!</h1>;
}

The test:

src/components/Greeting.test.tsx
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 from screen. (you used to pull them off render’s return value, but screen is 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.

Testing Library priority
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 attribute

Try 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.

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:

getByRole examples
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 its aria-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 + input
<label htmlFor="email">Email</label>
<input id="email" type="email" />

// or

<label>
  Email
  <input type="email" />
</label>
finding it
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.

getByText
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.

testid
<div data-testid="cart-total">$42.50</div>
finding it
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 foundIf multiple foundAsync
getBy*throwsthrowssync
queryBy*returns nullthrowssync
findBy*waits up to 1s, then throwsthrowsPromise (async)

There are also plural forms (*All*) — getAllByRole, findAllByText, etc.

What each is for:

getBy — must exist
expect(screen.getByRole('button', { name: 'Save' })).toBeInTheDocument();
// throws immediately if not found — intent is clear
queryBy — must NOT exist
expect(screen.queryByRole('alert')).not.toBeInTheDocument();
// using getByRole would throw on not-found and the assertion never runs
findBy — appears later
await screen.findByRole('alert');
// for toasts, error messages, etc. that show up asynchronously

Common slip-ups:

  • Using getByRole to assert “this button is not on the screen” — when it’s missing, the test fails immediately with an error. You need queryBy*.
  • Using a sync query for an async result — “at the moment of the lookup, it hasn’t rendered yet.” Use findBy* or waitFor (#4).

One click — the counter #

A second component.

src/components/Counter.tsx
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:

src/components/Counter.test.tsx
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 a userEvent instance. The recommended pattern is to call setup() 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.

a busy 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:

within
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.

frequently used jest-dom
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 not display: none and 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.

dump current DOM
render(<MyComponent />);
screen.debug();  // prints the current DOM tree to the console

Or just one element:

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.

Playground URL
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:

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 from screen.
  • Query priority: getByRolegetByLabelTextgetByText → … → getByTestId (last resort).
  • Separate the places for getBy* (must exist, sync) / queryBy* (may not exist, sync) / findBy* (appears later, async).
  • Prefer userEvent over fireEvent. Call userEvent.setup() once per test.
  • jest-dom matchers (toBeInTheDocument, toBeVisible, toHaveValue, …) are powerful once set up.
  • When stuck, reach for screen.debug() and screen.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.

X