React Testing Library: Complete Guide with Examples

React Testing Library: Complete Guide with Examples

React Testing Library (RTL) has become the standard for testing React components. It replaced Enzyme as the go-to tool because it tests behavior, not implementation. If you're still testing internal state or calling component methods directly, you're doing it wrong — and RTL is here to fix that.

This guide covers everything: installation, queries, user interactions, async testing, custom hooks, and the mistakes that will burn you if you don't know about them.

What React Testing Library Is and Why It Matters

The core philosophy is simple: test your components the way users use them. Users don't care about your state variables or which lifecycle methods fire. They see a button, they click it, something happens. Your tests should do the same.

RTL wraps React's test utilities and provides an API that queries the DOM the same way a user would find elements — by text, label, role, and placeholder. This means your tests stay valid even if you refactor internals completely, as long as behavior stays the same.

Two concrete wins:

  1. Tests break when behavior breaks, not when implementation changes
  2. You're forced to write accessible markup (because queries like getByRole and getByLabelText only work when your HTML is semantically correct)

The library is maintained by the Testing Library organization and ships with Create React App by default. It pairs with Jest (test runner + assertions) and @testing-library/user-event (realistic user interaction simulation).

Installation and Setup

If you're on Create React App or Vite with the React template, @testing-library/react and @testing-library/jest-dom are likely already installed. Verify:

npm list @testing-library/react

For a fresh install:

npm install --save-dev @testing-library/react @testing-library/user-event @testing-library/jest-dom

Then configure jest-dom matchers. In your Jest setup file (jest.setup.js or setupTests.js):

import '@testing-library/jest-dom';

Make sure Jest knows about it in jest.config.js:

module.exports = {
  setupFilesAfterFramework: ['./jest.setup.js'],
};

Or in package.json:

{
  "jest": {
    "setupFilesAfterFramework": ["@testing-library/jest-dom"]
  }
}

With Vitest (increasingly common in 2026), the setup is the same but point to vitest.setup.js in your vitest.config.ts.

Core Concepts

render and screen

render mounts your component into a virtual DOM. screen gives you access to that DOM with query methods.

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

test('displays user name', () => {
  render(<UserCard name="Alice" role="Admin" />);
  expect(screen.getByText('Alice')).toBeInTheDocument();
  expect(screen.getByText('Admin')).toBeInTheDocument();
});

Always prefer screen over the return value of render. The screen object always has access to the current DOM and makes tests easier to read.

Queries: getBy, findBy, queryBy

This is where most developers get confused. There are three query prefixes, each with different behavior:

Prefix Found Not Found Multiple
getBy returns element throws error throws error
queryBy returns element returns null throws error
findBy returns Promise rejects after timeout rejects

Use getBy when the element must be there. It gives you a clear error if the element is missing.

// This will throw a useful error if the button isn't rendered
const button = screen.getByRole('button', { name: /submit/i });

Use queryBy to assert something is NOT in the DOM.

// Assert that an error message is not visible
expect(screen.queryByText('Something went wrong')).not.toBeInTheDocument();

Use findBy for async elements that appear after a fetch, timer, or state update.

// Waits up to 1000ms (default) for element to appear
const message = await screen.findByText('Saved successfully');

Query Types: What to Query By

Priority order (highest to lowest):

  1. getByRole — buttons, inputs, headings, links. This is your default.
  2. getByLabelText — form inputs associated with a label
  3. getByPlaceholderText — when no label exists (but fix your HTML first)
  4. getByText — non-interactive text content
  5. getByDisplayValue — current value of form elements
  6. getByAltText — images
  7. getByTitle — elements with a title attribute
  8. getByTestId — last resort, use data-testid attribute

Example using role:

// Finds <button>Save changes</button>
screen.getByRole('button', { name: /save changes/i });

// Finds <h1>Dashboard</h1>
screen.getByRole('heading', { level: 1, name: /dashboard/i });

// Finds <input type="email" /> with associated label
screen.getByRole('textbox', { name: /email/i });

Testing Interactive Components

Click Events

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

test('increments counter on button click', async () => {
  const user = userEvent.setup();
  render(<Counter />);

  expect(screen.getByText('Count: 0')).toBeInTheDocument();

  await user.click(screen.getByRole('button', { name: /increment/i }));

  expect(screen.getByText('Count: 1')).toBeInTheDocument();
});

Note: always use userEvent.setup() and await the interactions. The older userEvent.click() API (without setup) is synchronous and doesn't simulate the full browser event sequence. The setup-based API fires pointerdown, mousedown, pointerup, mouseup, and click in order — exactly like a real browser.

Typing Into Inputs

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

test('filters results as user types', async () => {
  const user = userEvent.setup();
  render(<SearchBox />);

  const input = screen.getByRole('textbox', { name: /search/i });
  await user.type(input, 'react');

  expect(input).toHaveValue('react');
  expect(screen.getByText('Results for: react')).toBeInTheDocument();
});

user.type simulates keydown, keypress, input event, keyup for every character. Use user.clear(input) to clear a field before typing new content.

Form Submission

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 handleSubmit = jest.fn();
  const user = userEvent.setup();

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

  await user.type(screen.getByLabelText(/email/i), 'alice@example.com');
  await user.type(screen.getByLabelText(/password/i), 'secret123');
  await user.click(screen.getByRole('button', { name: /log in/i }));

  expect(handleSubmit).toHaveBeenCalledWith({
    email: 'alice@example.com',
    password: 'secret123',
  });
});

Async Testing with waitFor and findBy

React components often update asynchronously — after a fetch, a debounce, or a timer. Two tools handle this:

findBy queries

The simplest approach when you're waiting for a single element to appear:

test('loads and displays user profile', async () => {
  render(<UserProfile userId="42" />);

  // Shows loading state first
  expect(screen.getByText(/loading/i)).toBeInTheDocument();

  // Wait for the name to appear (up to 1000ms by default)
  const name = await screen.findByText('Alice Johnson');
  expect(name).toBeInTheDocument();

  // Loading spinner is gone
  expect(screen.queryByText(/loading/i)).not.toBeInTheDocument();
});

waitFor

Use waitFor when you need to assert something other than element presence, or when you have multiple assertions to check after an async operation:

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

test('shows success message after form submit', async () => {
  const user = userEvent.setup();
  render(<ContactForm />);

  await user.type(screen.getByLabelText(/message/i), 'Hello there');
  await user.click(screen.getByRole('button', { name: /send/i }));

  await waitFor(() => {
    expect(screen.getByText('Message sent!')).toBeInTheDocument();
    expect(screen.getByRole('button', { name: /send/i })).toBeDisabled();
  });
});

waitFor retries the callback until it passes or times out (default 1000ms). Don't put side effects inside waitFor — only assertions.

Mocking fetch

beforeEach(() => {
  global.fetch = jest.fn().mockResolvedValue({
    ok: true,
    json: async () => ({ id: 1, name: 'Alice', email: 'alice@example.com' }),
  });
});

afterEach(() => {
  jest.restoreAllMocks();
});

test('fetches and renders user data', async () => {
  render(<UserProfile userId="1" />);

  const name = await screen.findByText('Alice');
  expect(name).toBeInTheDocument();
  expect(screen.getByText('alice@example.com')).toBeInTheDocument();
});

Testing Custom Hooks

Custom hooks can't be rendered directly — they're not components. Use renderHook from RTL:

import { renderHook, act } from '@testing-library/react';
import useCounter from './useCounter';

test('initializes with default value', () => {
  const { result } = renderHook(() => useCounter());
  expect(result.current.count).toBe(0);
});

test('increments count', () => {
  const { result } = renderHook(() => useCounter(5));

  act(() => {
    result.current.increment();
  });

  expect(result.current.count).toBe(6);
});

test('resets to initial value', () => {
  const { result } = renderHook(() => useCounter(10));

  act(() => {
    result.current.increment();
    result.current.increment();
    result.current.reset();
  });

  expect(result.current.count).toBe(10);
});

For hooks that use context, wrap the hook in the required provider:

test('reads value from context', () => {
  const wrapper = ({ children }) => (
    <ThemeProvider theme="dark">{children}</ThemeProvider>
  );

  const { result } = renderHook(() => useTheme(), { wrapper });
  expect(result.current.theme).toBe('dark');
});

Common Mistakes and Anti-Patterns

1. Testing implementation details

// BAD: tests internal state
expect(component.state('isOpen')).toBe(true);

// GOOD: tests what the user sees
expect(screen.getByRole('dialog')).toBeInTheDocument();

2. Using container.querySelector

// BAD: bypasses accessibility queries, brittle
const button = container.querySelector('.submit-btn');

// GOOD: queries by role
const button = screen.getByRole('button', { name: /submit/i });

If you can't find your element with getByRole or getByLabelText, that's a signal your markup needs to be more accessible — fix the HTML, not the test.

3. Not awaiting user events

// BAD: old synchronous API, incomplete event simulation
userEvent.click(button);

// GOOD: full async event simulation
const user = userEvent.setup();
await user.click(button);

4. Wrapping assertions in act() unnecessarily

RTL wraps its own API calls in act already. Manually wrapping async state updates that happen inside user events or waitFor is redundant and can hide real warnings.

// BAD: unnecessary act wrapper
await act(async () => {
  await user.click(button);
});

// GOOD: user-event handles act internally
await user.click(button);

5. Using getBy for elements that might not exist

// BAD: will throw if no error message rendered yet
expect(screen.getByText('Error')).not.toBeInTheDocument();

// GOOD: queryBy returns null instead of throwing
expect(screen.queryByText('Error')).not.toBeInTheDocument();

6. Asserting on snapshots of large components

Snapshot tests for entire page components catch every DOM change — including harmless ones. You end up updating snapshots without reading them. Use snapshots for small, stable UI components only. For behavior, write explicit assertions.

7. Forgetting to clean up

RTL auto-cleans between tests if you're using @testing-library/react v13+. But if you're mocking global objects (fetch, localStorage, timers), reset them in afterEach:

afterEach(() => {
  jest.clearAllMocks();
  localStorage.clear();
});

Beyond Unit Tests: Integration with HelpMeTest

React Testing Library is excellent for unit and integration testing at the component level. But there's a gap: RTL tests run against a virtual DOM with mocked APIs. They don't catch issues that only surface in real browsers — CSS rendering bugs, third-party script conflicts, real network conditions, cross-browser behavior.

That's where end-to-end testing comes in — and where most teams either skip it (too slow to write), or write brittle Playwright scripts that break on every UI change.

HelpMeTest is built for this gap. You write tests in plain English — "click the submit button, expect 'Saved' to appear" — and the platform runs them in real browsers using Playwright under the hood. When your UI changes, tests self-heal instead of failing on stale selectors.

A practical setup for a React app:

  1. RTL handles component behavior: unit logic, form validation, state transitions
  2. HelpMeTest handles end-to-end flows: login → create → update → delete, cross-page navigation, real API calls

Example: you've unit-tested your checkout form with RTL. The button works, the validation fires, the onSubmit mock gets called. But does the real checkout flow work end-to-end against your staging API, with the real payment form loaded in an actual browser? RTL can't answer that. HelpMeTest can.

Write the test once in natural language:

Go to /checkout
Fill "Card number" with "4242 4242 4242 4242"
Fill "Expiry" with "12/28"
Fill "CVV" with "123"
Click "Pay now"
Expect "Payment successful" to be visible

HelpMeTest runs it on a schedule — every hour, every push, or both — and alerts you when it fails. The free plan covers 10 tests with 24/7 monitoring. The Pro plan at $100/month is unlimited.

The combination covers the full testing pyramid:

  • Fast RTL unit tests for component logic (runs in milliseconds, part of CI)
  • HelpMeTest E2E tests for critical user flows (runs in real browsers, monitored continuously)

Summary

React Testing Library works because it aligns your tests with your users' perspective. The key discipline: query by role and label, interact through user-event, assert on visible output. Never test internal state.

Quick reference:

  • getBy — must exist, throws if missing
  • queryBy — may not exist, returns null
  • findBy — async, returns Promise
  • userEvent.setup() + await user.click/type — realistic interactions
  • waitFor — async assertions beyond element presence
  • renderHook — test custom hooks in isolation

Once your component tests are solid, close the gap with end-to-end monitoring. Your RTL suite tells you components work in isolation. HelpMeTest tells you your product works for real users.


Start covering your critical flows today — HelpMeTest is free for up to 10 tests, no credit card required.

Read more