Skip to content

React Testing Library and Queries

intermediate18 min read

Stop Testing Implementation Details

Here's the thing most developers get wrong about testing React components: they test how the component works instead of what it does. They reach for internal state, check if a specific function was called, or assert on CSS class names. Then they refactor the component and every test breaks — even though the user-facing behavior is identical.

React Testing Library (RTL) was built around one radical idea:

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

This isn't just philosophy. It's a practical strategy. When you test through the user's lens — clicking buttons, reading text, filling forms — your tests survive refactors. They catch real bugs. They give you actual confidence before shipping.

Mental Model

Imagine you're testing a vending machine. You wouldn't pop off the front panel and check if the motor spins at 3000 RPM when you press B4. You'd insert money, press the button, and check that a Snickers drops out. That's what RTL does — it tests the machine from the outside, the same way real users interact with it.

The render Function

Everything starts with render. It takes your component, mounts it into a real DOM node (via jsdom), and gives you access to query it:

import { render, screen } from '@testing-library/react';
import { Greeting } from './Greeting';

test('shows a welcome message', () => {
  render(<Greeting name="Sarah" />);

  expect(screen.getByText('Welcome, Sarah!')).toBeInTheDocument();
});

A few things to notice:

  • render returns query methods bound to the rendered container, but you should prefer the screen object instead (more on that next)
  • The component renders into a real DOM — you can query it with the same semantics a user or screen reader would use
  • No shallow rendering. RTL mounts the full component tree, including children. This catches integration bugs that shallow rendering misses

render also returns an unmount function and a container reference, but you'll rarely need them:

const { unmount, container } = render(<Timer />);
// container is the wrapping div — avoid querying it directly
// unmount() removes the component from the DOM
Warning

Avoid destructuring queries from render. Use screen instead — it makes tests easier to read and keeps the query source consistent across your test file.

Quiz
Why does React Testing Library use full rendering instead of shallow rendering?

The screen Object

screen is your primary interface for querying the rendered DOM. It's a global export from RTL that automatically binds to the last rendered container:

import { render, screen } from '@testing-library/react';

test('displays user info', () => {
  render(<UserCard name="Alex" role="Engineer" />);

  // All queries are available on screen
  screen.getByRole('heading', { name: 'Alex' });
  screen.getByText('Engineer');
});

Why screen over destructured queries? Three reasons:

  1. Readabilityscreen.getByRole(...) is immediately clear. Destructured getByRole(...) could come from anywhere
  2. Consistency — same API surface in every test, regardless of how many renders you do
  3. Debuggingscreen.debug() is always available without extra imports

Query Priority — The Heart of RTL

Not all queries are created equal. RTL provides a deliberate priority order. Queries higher on the list reflect how users and assistive technology find elements. Queries lower on the list are escape hatches.

PriorityQueryWhen to Use
1 (best)getByRoleAccessible role + name — buttons, headings, links, inputs
2getByLabelTextForm fields with associated labels
3getByPlaceholderTextInput with placeholder (when no label exists)
4getByTextNon-interactive text content
5getByDisplayValueCurrent value of an input, select, or textarea
6getByAltTextImages with alt text
7getByTitleElements with title attribute (rare)
8 (last resort)getByTestIdWhen no semantic query works — data-testid attribute

getByRole — Your Default Query

getByRole should be your first instinct for every query. It queries the accessibility tree, which means your tests implicitly verify that your component is accessible:

// Buttons
screen.getByRole('button', { name: 'Submit' });
screen.getByRole('button', { name: /submit/i });

// Headings
screen.getByRole('heading', { name: 'Dashboard' });
screen.getByRole('heading', { level: 2 });

// Links
screen.getByRole('link', { name: 'View profile' });

// Form elements
screen.getByRole('textbox', { name: 'Email' });
screen.getByRole('checkbox', { name: 'Accept terms' });
screen.getByRole('combobox', { name: 'Country' });

The name option matches the element's accessible name — the text that a screen reader would announce. For a button, that's its text content. For an input, it's the associated label text.

Tip

If you can't find an element with getByRole, that's not a testing problem — it's an accessibility problem. Fix the component, not the query.

getByLabelText — For Form Fields

When an input has a proper label, getByLabelText is the most user-realistic query:

render(
  <form>
    <label htmlFor="email">Email address</label>
    <input id="email" type="email" />
  </form>
);

screen.getByLabelText('Email address');

This query mirrors exactly how a user (or screen reader) finds a form field — by reading its label.

getByText — For Static Content

Use getByText for non-interactive content where role-based queries don't apply:

screen.getByText('No results found');
screen.getByText(/no results/i); // regex for flexible matching

getByTestId — The Escape Hatch

data-testid should be your last resort, not your first. It's for cases where no semantic query works — like a dynamically styled container with no text or ARIA role:

// In the component
<div data-testid="color-swatch" style={{ background: color }} />

// In the test
screen.getByTestId('color-swatch');
Quiz
Which query should you try first when testing a form submit button?

get vs query vs find — Three Query Variants

Every query type (getByRole, getByText, etc.) comes in three variants. Understanding when to use each is critical:

VariantReturnsWhen Element MissingAsync?Use Case
getByElementThrows error immediatelyNoElement should exist right now
queryByElement or nullReturns nullNoAssert element does NOT exist
findByPromise of elementRejects after timeoutYesElement will appear after async operation

getBy — Element Must Exist

// Throws if not found — perfect for asserting presence
const button = screen.getByRole('button', { name: 'Save' });
expect(button).toBeEnabled();

queryBy — Assert Absence

// Returns null instead of throwing — only way to test "not there"
expect(screen.queryByText('Error: invalid input')).not.toBeInTheDocument();

// Don't use getBy for this — it throws before the assertion runs!

findBy — Wait for Async

// Returns a promise — waits for element to appear (default 1000ms)
test('loads user data', async () => {
  render(<UserProfile userId="123" />);

  // Component shows loading initially, then fetches data
  const heading = await screen.findByRole('heading', { name: 'Alex' });
  expect(heading).toBeInTheDocument();
});

findBy is essentially a combination of getBy + waitFor. It retries the query until the element appears or the timeout expires.

Common Trap

A common mistake is using getBy in an async test and wrapping it in waitFor manually. Just use findBy — it's cleaner and does the same thing. Reserve waitFor for assertions that don't involve finding elements, like checking if a callback was called.

Quiz
How do you test that an error message is NOT displayed?

User Events — Simulating Real Interactions

RTL provides two ways to simulate user interactions: fireEvent and userEvent. You should almost always use userEvent.

Why userEvent over fireEvent

fireEvent dispatches a single DOM event. userEvent simulates full user interactions — including all the intermediate events that a real browser fires:

import userEvent from '@testing-library/user-event';

test('typing in a search field', async () => {
  const user = userEvent.setup();
  render(<SearchBar />);

  const input = screen.getByRole('searchbox', { name: 'Search' });

  // userEvent.type fires: focus, keydown, keypress, input, keyup
  // for EACH character — just like a real user typing
  await user.type(input, 'react testing');

  expect(input).toHaveValue('react testing');
});

When you call fireEvent.change(input, { target: { value: 'react testing' } }), you fire a single change event with the full value. That skips focus, individual keystrokes, and input events. If your component relies on any of those intermediate events (debounce on keyup, validation on each keystroke), fireEvent won't catch the bug.

Common userEvent Methods

const user = userEvent.setup();

// Clicking
await user.click(button);
await user.dblClick(element);
await user.tripleClick(textField); // selects all text

// Typing
await user.type(input, 'hello');
await user.clear(input); // clears input value
await user.type(input, '{Enter}'); // special keys

// Selection
await user.selectOptions(select, 'option-value');

// Keyboard
await user.keyboard('{Shift>}A{/Shift}'); // Shift+A
await user.tab(); // move focus

// Clipboard
await user.copy();
await user.paste();

// Hover
await user.hover(element);
await user.unhover(element);
Info

Always call userEvent.setup() at the start of each test and use the returned user instance. This ensures proper event timing and avoids stale state between interactions.

A Realistic Form Test

Here's how these pieces come together in a real test:

import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { LoginForm } from './LoginForm';

test('submits form with email and password', async () => {
  const user = userEvent.setup();
  const handleSubmit = vi.fn();

  render(<LoginForm onSubmit={handleSubmit} />);

  await user.type(
    screen.getByRole('textbox', { name: 'Email' }),
    'alex@example.com'
  );

  await user.type(
    screen.getByLabelText('Password'),
    'secure-password-123'
  );

  await user.click(
    screen.getByRole('button', { name: 'Sign in' })
  );

  expect(handleSubmit).toHaveBeenCalledWith({
    email: 'alex@example.com',
    password: 'secure-password-123',
  });
});

Notice: no test IDs, no implementation details, no internal state checks. The test reads like a user story.

Quiz
What is the main advantage of userEvent.type() over fireEvent.change()?

waitFor — Async Assertions

Sometimes you need to wait for something that isn't an element appearing. Maybe a callback fires after a debounce, or a class name changes after an animation. That's where waitFor comes in:

import { render, screen, waitFor } from '@testing-library/react';

test('debounced search calls API after 300ms', async () => {
  const user = userEvent.setup();
  const onSearch = vi.fn();

  render(<SearchBar onSearch={onSearch} debounceMs={300} />);

  await user.type(screen.getByRole('searchbox'), 'react');

  // onSearch hasn't been called yet — it's debounced
  expect(onSearch).not.toHaveBeenCalled();

  // Wait for the debounce to fire
  await waitFor(() => {
    expect(onSearch).toHaveBeenCalledWith('react');
  });
});

waitFor retries the callback until it passes or the timeout expires (default 1000ms). It uses a polling interval (default 50ms) to re-run your assertion.

waitFor vs findBy

Use findBy when you're looking for an element. Use waitFor for any other async assertion:

// Finding an element → use findBy
const message = await screen.findByText('Saved successfully');

// Non-element assertion → use waitFor
await waitFor(() => {
  expect(mockFn).toHaveBeenCalledTimes(1);
});
Warning

Never put side effects inside waitFor. It retries the callback multiple times, so await user.click(button) inside waitFor would click the button repeatedly. Only put assertions inside waitFor.

within — Scoped Queries

When your page has multiple sections with similar content, within lets you scope queries to a specific container:

import { render, screen, within } from '@testing-library/react';

test('each team section shows member count', () => {
  render(<TeamDashboard />);

  const engineeringSection = screen.getByRole('region', {
    name: 'Engineering',
  });
  const designSection = screen.getByRole('region', {
    name: 'Design',
  });

  expect(
    within(engineeringSection).getByText('12 members')
  ).toBeInTheDocument();

  expect(
    within(designSection).getByText('5 members')
  ).toBeInTheDocument();
});

Without within, getByText('12 members') would find the first match anywhere in the document — which might not be in the section you intended to test.

Scoping to List Items

within is especially useful for testing lists where each item has the same structure:

test('renders todo items with correct status', () => {
  render(<TodoList todos={mockTodos} />);

  const items = screen.getAllByRole('listitem');

  expect(within(items[0]).getByText('Buy groceries')).toBeInTheDocument();
  expect(within(items[0]).getByRole('checkbox')).toBeChecked();

  expect(within(items[1]).getByText('Clean house')).toBeInTheDocument();
  expect(within(items[1]).getByRole('checkbox')).not.toBeChecked();
});

Debugging Tests

When a test fails and you can't figure out why, RTL gives you powerful debugging tools.

screen.debug()

Prints the current DOM tree to the console:

test('debugging example', () => {
  render(<Navigation />);

  screen.debug(); // prints entire DOM
  screen.debug(screen.getByRole('nav')); // prints just the nav element
});

By default, debug() truncates output at 7000 characters. For larger components, increase the limit:

screen.debug(undefined, 30000); // print up to 30000 chars

logRoles

When you can't figure out what roles are available, logRoles prints every element with an implicit or explicit ARIA role:

import { logRoles } from '@testing-library/react';

test('inspect available roles', () => {
  const { container } = render(<ComplexForm />);

  logRoles(container);
  // Output:
  // form:
  //   <form />
  // textbox:
  //   <input type="text" />
  //   <textarea />
  // button:
  //   <button />
  //   <input type="submit" />
  // checkbox:
  //   <input type="checkbox" />
});

This is incredibly useful when getByRole isn't finding what you expect — it tells you exactly what roles RTL can see.

Testing Playground

For tricky queries, paste your component's HTML into testing-playground.com. It suggests the best query for any element you click on. You can also use the browser extension or call screen.logTestingPlaygroundURL() to generate a link directly from your test.

How RTL Queries Map to Accessibility

RTL's query priority isn't arbitrary — it mirrors the Web Content Accessibility Guidelines (WCAG). Here's the connection:

getByRole queries the accessibility tree, which is the browser's internal representation of the page structure for assistive technology. Every HTML element has an implicit role: button elements have role "button", h1 has role "heading", a has role "link". When you can find an element by its role, it means screen readers can find it too.

getByLabelText tests the programmatic label association that screen readers rely on to describe form fields. If this query fails, your form is likely inaccessible.

getByText simulates visual text scanning — the most basic way any user finds content on a page.

getByTestId has zero accessibility meaning. It's invisible to users and assistive technology. That's why it's the last resort — it doesn't test any real user interaction pattern.

By following RTL's query priority, you're not just writing tests. You're building an accessibility safety net that catches regressions automatically.

Quiz
Your test needs to find a specific row in a data table that has duplicate column values across rows. What approach should you use?

Putting It All Together — Query Decision Flowchart

When you're staring at a component wondering which query to use, follow this mental checklist:

  1. Is it interactive (button, link, input)?getByRole
  2. Is it a form field with a label?getByLabelText or getByRole with name
  3. Is it visible text?getByText
  4. Is it an image?getByAltText
  5. Does it appear after an async operation?findByRole, findByText, etc.
  6. Do you need to assert it's NOT there?queryByRole, queryByText, etc.
  7. Nothing else works?getByTestId (and consider fixing the component's semantics)
Key Rules
  1. 1Use getByRole as your default query — it tests accessibility for free
  2. 2Use queryBy variants only when asserting an element does NOT exist
  3. 3Use findBy variants for elements that appear after async operations
  4. 4Prefer userEvent over fireEvent — it simulates real user behavior
  5. 5Call userEvent.setup() at the start of each test for proper event timing
  6. 6Use within to scope queries when the page has duplicate content
  7. 7Never put side effects inside waitFor — only assertions
  8. 8If getByRole can't find your element, fix the component's accessibility first
What developers doWhat they should do
Reach for getByTestId first because it's reliable
Test IDs don't verify accessibility and create coupling to implementation. If you can't find the element with semantic queries, the component likely has an accessibility problem.
Start with getByRole, fall back through the query priority list
Test internal state, prop callbacks, or CSS class names
Implementation detail tests break on refactors even when behavior is unchanged. Test what the user sees and does, not how the component achieves it.
Test visible output and user-facing behavior
Use fireEvent for all interactions
fireEvent dispatches a single event. Real users trigger sequences of events (focus, keydown, input, keyup, change). userEvent simulates the full sequence, catching bugs that fireEvent misses.
Use userEvent.setup() and the returned user instance
Use getBy inside async tests and wrap with waitFor
findBy is the idiomatic shorthand for getBy + waitFor. It's cleaner, more readable, and communicates intent — this element will appear asynchronously.
Use findBy for waiting on elements to appear
Destructure queries from render() result
screen is consistent across tests, self-documenting, and always provides screen.debug() without extra setup. Destructured queries are ambiguous about their source.
Import and use screen for all queries
Quiz
What does logRoles(container) help you debug?