Testing

React Testing Library: Writing Better Tests

Mayur Dabhi
Mayur Dabhi
April 19, 2026
15 min read

You rename a CSS class, or move some state from useState into a reducer, and twenty tests go red. Nothing is broken for the user. The button still works, the form still submits. The tests were checking which class was applied and which internal value changed, so any refactor breaks them. Meanwhile a real bug, like a submit button that never re-enables after an error, slips through because nobody tested what the user actually sees.

React Testing Library (RTL) is built to stop that from happening. The idea is to test your components the way people use them: find things by their label or role, click them, and check what shows up on screen. This post covers setup, the query types and which one to pick, async patterns, mocking with MSW and jest.mock, and the mistakes that make RTL tests flaky or brittle.

The RTL Philosophy

Kent C. Dodds, who created the library, put it this way: "The more your tests resemble the way your software is used, the more confidence they can give you." In practice that means querying by visible text and ARIA roles, not CSS selectors or component internals.

Setup and Installation

If you're using Create React App or Vite with the React template, RTL is included by default alongside Jest. For manual setup, here's what you need:

1

Install dependencies

Add RTL, jest-environment-jsdom, and @testing-library/user-event for realistic interaction simulation.

Terminal
# npm
npm install --save-dev @testing-library/react @testing-library/jest-dom @testing-library/user-event jest jest-environment-jsdom

# yarn
yarn add --dev @testing-library/react @testing-library/jest-dom @testing-library/user-event jest jest-environment-jsdom

# pnpm
pnpm add -D @testing-library/react @testing-library/jest-dom @testing-library/user-event jest jest-environment-jsdom
2

Configure Jest

Set up jest.config.js with jsdom environment and the RTL custom matchers setup file.

jest.config.js
/** @type {import('jest').Config} */
const config = {
  testEnvironment: 'jest-environment-jsdom',
  setupFilesAfterFramework: ['@testing-library/jest-dom'],
  // For Vite / ESM projects, add a transform:
  transform: {
    '^.+\\.(js|jsx|ts|tsx)$': 'babel-jest',
  },
  moduleNameMapper: {
    // Handle CSS/asset imports in tests
    '\\.(css|less|scss|sass)$': 'identity-obj-proxy',
    '\\.(jpg|jpeg|png|gif|svg)$': '/__mocks__/fileMock.js',
  },
};

module.exports = config;
3

Create a global setup file

Import @testing-library/jest-dom once so custom matchers like toBeInTheDocument() are available in every test file. Watch the config key name: Jest's option is setupFilesAfterEnv. If it's misspelled, Jest only prints an "Unknown option" validation warning, and then every test fails with TypeError: expect(...).toBeInTheDocument is not a function.

src/setupTests.js
import '@testing-library/jest-dom';

// Optional: reset mocks between tests
beforeEach(() => {
  jest.clearAllMocks();
});

Queries: Finding Elements the Right Way

Most of your time in RTL goes into queries: how the test finds an element in the DOM. The query you're able to use also tells you something about accessibility. If you can't find a button by its role and name, a screen reader user probably can't either. There are three families, and they differ in what happens when the element isn't there.

getBy... ✓ Element exists now ✗ Throws if missing ✗ Throws if multiple Use for: elements that must be present Most common choice queryBy... ✓ Element may be absent ✓ Returns null if missing ✗ Throws if multiple Use for: asserting an element is NOT there For absence checks findBy... ✓ Returns a Promise ✓ Retries until found ✗ Rejects after timeout Use for: elements that appear asynchronously For async UI Each family also has AllBy, queryAllBy, findAllBy variants for multiple elements

RTL query families: pick based on whether the element should be there now, later, or not at all

Query Priority: Which to Use When

The RTL docs suggest this order, starting with the queries that match how users and assistive tech find things:

Priority Query What It Finds Example
1 (Best) getByRole ARIA role + accessible name getByRole('button', {name: /submit/i})
2 getByLabelText Form inputs by label getByLabelText(/email/i)
3 getByPlaceholderText Input placeholder text getByPlaceholderText('Search...')
4 getByText Visible text content getByText(/welcome back/i)
5 getByDisplayValue Current form field value getByDisplayValue('John')
6 getByAltText Image alt text getByAltText(/user avatar/i)
7 getByTitle title attribute getByTitle('Close modal')
8 (Last resort) getByTestId data-testid attribute getByTestId('submit-btn')
Avoid data-testid as default

If getByTestId is your first reach, the component is probably missing the roles, labels, or visible text that screen readers rely on. Fix the markup first. Keep test IDs for cases where there's genuinely nothing semantic to query, like a canvas or a purely decorative wrapper.

Your First RTL Test

Here's a small login form. Nothing fancy, but it has the three things you'll test constantly: labeled inputs, a submit handler, and an error that shows up after an async call.

src/components/LoginForm.jsx
import { useState } from 'react';

export default function LoginForm({ onLogin }) {
  const [email, setEmail] = useState('');
  const [password, setPassword] = useState('');
  const [error, setError] = useState('');

  async function handleSubmit(e) {
    e.preventDefault();
    setError('');
    try {
      await onLogin(email, password);
    } catch (err) {
      setError(err.message);
    }
  }

  return (
    <form onSubmit={handleSubmit}>
      <h1>Sign In</h1>

      {error && <p role="alert">{error}</p>}

      <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">Sign In</button>
    </form>
  );
}

And the tests, using @testing-library/user-event to type and click:

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

describe('LoginForm', () => {
  // userEvent.setup() creates a user session that simulates real browser events
  const user = userEvent.setup();

  it('renders the form fields', () => {
    render(<LoginForm onLogin={jest.fn()} />);

    expect(screen.getByRole('heading', { name: /sign in/i })).toBeInTheDocument();
    expect(screen.getByLabelText(/email/i)).toBeInTheDocument();
    expect(screen.getByLabelText(/password/i)).toBeInTheDocument();
    expect(screen.getByRole('button', { name: /sign in/i })).toBeInTheDocument();
  });

  it('calls onLogin with email and password when form is submitted', async () => {
    const mockLogin = jest.fn().mockResolvedValue(undefined);
    render(<LoginForm onLogin={mockLogin} />);

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

    expect(mockLogin).toHaveBeenCalledWith('test@example.com', 'supersecret');
    expect(mockLogin).toHaveBeenCalledTimes(1);
  });

  it('displays an error message when login fails', async () => {
    const mockLogin = jest.fn().mockRejectedValue(new Error('Invalid credentials'));
    render(<LoginForm onLogin={mockLogin} />);

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

    // The error appears asynchronously after the rejected promise
    expect(await screen.findByRole('alert')).toHaveTextContent('Invalid credentials');
  });
});
userEvent vs fireEvent

Default to userEvent. fireEvent dispatches one event. userEvent fires the sequence a browser would (focus, keydown, input, keyup, and so on), so it catches bugs like a handler wired to onKeyDown that fireEvent.change would skip. It also won't click a disabled button, which fireEvent happily does. Note that in v14 every userEvent call returns a promise, so forgetting await is a common source of "not wrapped in act(...)" warnings.

Async Testing Patterns

Most flaky RTL tests I've seen come from async code: an assertion runs before the fetch resolves, or before a state update lands. RTL gives you a few tools for waiting properly instead of sprinkling setTimeout around.

findBy* queries return a promise and poll the DOM until the element appears (up to 1000ms by default). Use them when an element renders after an async operation:

it('shows user profile after login', async () => {
  render(<Dashboard />);

  // This button triggers an API call that fetches user data
  await userEvent.click(screen.getByRole('button', { name: /load profile/i }));

  // findByText waits up to 1000ms for the element to appear
  const heading = await screen.findByRole('heading', { name: /john doe/i });
  expect(heading).toBeInTheDocument();

  // You can also use findAllBy for multiple elements
  const posts = await screen.findAllByRole('article');
  expect(posts).toHaveLength(3);
});

waitFor retries a callback until it stops throwing or times out. Use it when the assertion itself is what becomes true asynchronously. Keep one assertion inside the callback, and don't put side effects like clicks in there, because they'll run on every retry:

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

it('removes loading spinner once data loads', async () => {
  render(<UserList />);

  // Spinner is visible initially
  expect(screen.getByRole('progressbar')).toBeInTheDocument();

  // Wait until the spinner disappears
  await waitFor(() => {
    expect(screen.queryByRole('progressbar')).not.toBeInTheDocument();
  });

  // Now user list should be visible
  expect(screen.getAllByRole('listitem')).toHaveLength(5);
});

// waitFor with custom timeout and interval
await waitFor(
  () => expect(screen.getByText('Done!')).toBeInTheDocument(),
  { timeout: 3000, interval: 100 }
);

Mock Service Worker (MSW) intercepts HTTP requests at the network layer. Your component's real fetch code runs, so you test more of the actual path than you would by mocking the API module:

// src/mocks/handlers.js
import { http, HttpResponse } from 'msw';

export const handlers = [
  http.get('/api/users', () => {
    return HttpResponse.json([
      { id: 1, name: 'Alice', email: 'alice@example.com' },
      { id: 2, name: 'Bob', email: 'bob@example.com' },
    ]);
  }),

  http.post('/api/login', async ({ request }) => {
    const body = await request.json();
    if (body.password === 'wrong') {
      return HttpResponse.json({ error: 'Invalid credentials' }, { status: 401 });
    }
    return HttpResponse.json({ token: 'abc123' });
  }),
];

// src/mocks/server.js
import { setupServer } from 'msw/node';
import { handlers } from './handlers';
export const server = setupServer(...handlers);

// jest.setup.js: start, reset and stop the server around tests
import { server } from './src/mocks/server';
beforeAll(() => server.listen());
afterEach(() => server.resetHandlers());
afterAll(() => server.close());

Testing Common Patterns

Testing Forms with Validation

Form validation test
it('shows validation errors for empty fields', async () => {
  const user = userEvent.setup();
  render(<RegistrationForm onSubmit={jest.fn()} />);

  // Submit without filling anything
  await user.click(screen.getByRole('button', { name: /register/i }));

  // Both error messages should appear
  expect(screen.getByText(/name is required/i)).toBeInTheDocument();
  expect(screen.getByText(/valid email is required/i)).toBeInTheDocument();
});

it('clears errors when user starts typing', async () => {
  const user = userEvent.setup();
  render(<RegistrationForm onSubmit={jest.fn()} />);

  // Trigger errors
  await user.click(screen.getByRole('button', { name: /register/i }));
  expect(screen.getByText(/name is required/i)).toBeInTheDocument();

  // Start typing, and the error should clear
  await user.type(screen.getByLabelText(/name/i), 'John');
  expect(screen.queryByText(/name is required/i)).not.toBeInTheDocument();
});

Testing Lists and Conditional Rendering

List and conditional rendering tests
describe('TodoList', () => {
  it('shows empty state when there are no todos', () => {
    render(<TodoList todos={[]} />);
    expect(screen.getByText(/no tasks yet/i)).toBeInTheDocument();
    expect(screen.queryByRole('list')).not.toBeInTheDocument();
  });

  it('renders all todos', () => {
    const todos = [
      { id: 1, text: 'Buy groceries', done: false },
      { id: 2, text: 'Walk the dog', done: true },
    ];
    render(<TodoList todos={todos} />);

    const items = screen.getAllByRole('listitem');
    expect(items).toHaveLength(2);
    expect(screen.getByText('Buy groceries')).toBeInTheDocument();
    expect(screen.getByText('Walk the dog')).toBeInTheDocument();
  });

  it('toggles a todo when clicked', async () => {
    const user = userEvent.setup();
    const onToggle = jest.fn();
    const todos = [{ id: 1, text: 'Read RTL docs', done: false }];

    render(<TodoList todos={todos} onToggle={onToggle} />);

    // Find the checkbox associated with the todo text
    const checkbox = screen.getByRole('checkbox', { name: /read rtl docs/i });
    await user.click(checkbox);

    expect(onToggle).toHaveBeenCalledWith(1);
  });
});
Test File userEvent, render screen queries render() RTL Renders into jsdom Exposes screen API real DOM jsdom Simulates browser DOM in Node.js MSW Intercepts HTTP reqs RTL test pipeline: no real browser required

How RTL, jsdom, and MSW fit together in a test run

Mocking Modules and Context

Mocking React Context

Render a component that calls useContext without its provider and you'll either get the default context value or a crash. Rather than wrapping providers by hand in every test, I'd write a custom render function once:

src/test-utils.jsx
import { render } from '@testing-library/react';
import { AuthContext } from './contexts/AuthContext';
import { ThemeProvider } from './contexts/ThemeContext';

// Default auth state for most tests
const defaultAuthValue = {
  user: { id: 1, name: 'Test User', email: 'test@example.com' },
  isAuthenticated: true,
  logout: jest.fn(),
};

// Custom render that wraps all required providers
function renderWithProviders(
  ui,
  { authValue = defaultAuthValue, ...options } = {}
) {
  function Wrapper({ children }) {
    return (
      <AuthContext.Provider value={authValue}>
        <ThemeProvider>
          {children}
        </ThemeProvider>
      </AuthContext.Provider>
    );
  }

  return render(ui, { wrapper: Wrapper, ...options });
}

// Re-export everything from RTL so tests only import from this file
export * from '@testing-library/react';
export { renderWithProviders as render };
Using the custom render in tests
// Import from our custom utils, not from RTL directly
import { render, screen } from '../test-utils';
import UserProfile from './UserProfile';

it('shows the logged-in user name', () => {
  render(<UserProfile />);
  expect(screen.getByText('Test User')).toBeInTheDocument();
});

it('shows guest message when not authenticated', () => {
  render(<UserProfile />, {
    authValue: { user: null, isAuthenticated: false, logout: jest.fn() },
  });
  expect(screen.getByText(/please log in/i)).toBeInTheDocument();
});

Mocking Modules with jest.mock

Mocking external modules
// Mock an entire module
jest.mock('../api/users', () => ({
  fetchUsers: jest.fn(),
  deleteUser: jest.fn(),
}));

import { fetchUsers } from '../api/users';
import UserList from './UserList';

it('displays users returned by the API', async () => {
  fetchUsers.mockResolvedValue([
    { id: 1, name: 'Alice' },
    { id: 2, name: 'Bob' },
  ]);

  render(<UserList />);

  expect(await screen.findByText('Alice')).toBeInTheDocument();
  expect(screen.getByText('Bob')).toBeInTheDocument();
});

it('shows an error state when API fails', async () => {
  fetchUsers.mockRejectedValue(new Error('Network error'));

  render(<UserList />);

  expect(await screen.findByRole('alert')).toHaveTextContent('Network error');
});

Custom Matchers and Assertions

@testing-library/jest-dom adds DOM-aware matchers to Jest. The main win is the failure output: toBeDisabled() tells you the element and why it failed, where expect(button.disabled).toBe(true) just says "expected true, received false".

jest-dom Custom Matchers Reference

Matcher What It Checks
toBeInTheDocument() Element is present in the DOM
toBeVisible() Element is visible to the user (not hidden/opacity:0)
toBeEnabled() / toBeDisabled() Form element enabled/disabled state
toBeChecked() Checkbox or radio is checked
toHaveValue(value) Input/select current value
toHaveTextContent(text) Element's text content matches
toHaveAttribute(attr, value) Element has a specific attribute/value
toHaveClass(className) Element has a CSS class (use sparingly)
toHaveFocus() Element is the currently focused element
toHaveStyle(css) Element has specific inline styles
toHaveAccessibleName(name) Element's accessible name matches
toHaveErrorMessage(msg) Element has an aria-errormessage

Common Mistakes and How to Fix Them

These show up in code reviews all the time, including from people who've used RTL for years.

Anti-pattern #1: Testing implementation details

Asserting on internal state, instance methods, or internal class names. Refactor the internals without changing behavior and these tests still break. Test what the user sees and does instead.

Bad vs Good: implementation details
// BAD: Tests internal state (breaks on refactor)
const { container } = render(<Counter />);
expect(container.firstChild).toHaveClass('counter--active');
expect(wrapper.state('count')).toBe(0);

// GOOD: Tests what the user sees
render(<Counter />);
expect(screen.getByText('Count: 0')).toBeInTheDocument();

await userEvent.click(screen.getByRole('button', { name: /increment/i }));
expect(screen.getByText('Count: 1')).toBeInTheDocument();
Anti-pattern #2: Using act() manually everywhere

RTL already wraps renders and user events in act(). Manually wrapping everything in act() adds noise and is usually a sign you should use waitFor or findBy instead.

Bad vs Good: act() usage
// BAD: Manual act() wrapping
import { act } from 'react-dom/test-utils';
act(() => {
  fireEvent.click(button);
});
act(() => {
  // wait for state update...
});

// GOOD: Let RTL handle act() through userEvent and findBy
await userEvent.click(button);
const result = await screen.findByText('Updated!');
Anti-pattern #3: Not cleaning up between tests

RTL calls cleanup() after each test automatically when you run it under Jest or Vitest with globals enabled. It only unmounts what render mounted, though. Fake timers, global subscriptions, and anything you stuck on window are yours to reset in afterEach, or one test will leak into the next.

Structuring a Scalable Test Suite

Once you have a few hundred tests, finding and maintaining them starts to matter as much as writing them. This is the layout I'd start with:

Recommended Test Organization

  • Co-locate tests: Keep Component.test.jsx next to Component.jsx. Easy to find, and they get deleted together
  • One describe block per component: Group related tests, use nested describe for sub-behaviors
  • Name tests as user behaviors: "shows error when email is invalid" not "email validation"
  • Share test utilities: Put shared render helpers, mocks, and fixture data in src/test-utils/
  • Use factories for test data: Functions that build fixture objects reduce copy-paste and keep tests readable
  • One assertion per test (loosely): Focused tests give clearer failure messages, but don't chop a single user flow into five tests that each re-render and re-type everything
Test data factory pattern
// src/test-utils/factories.js
export function buildUser(overrides = {}) {
  return {
    id: Math.random(),
    name: 'Test User',
    email: 'test@example.com',
    role: 'member',
    createdAt: '2026-01-01',
    ...overrides,
  };
}

export function buildPost(overrides = {}) {
  return {
    id: Math.random(),
    title: 'Test Post Title',
    body: 'Some test body content.',
    author: buildUser(),
    tags: [],
    publishedAt: '2026-01-15',
    ...overrides,
  };
}

// In tests:
it('shows admin badge for admin users', () => {
  const admin = buildUser({ role: 'admin' });
  render(<UserCard user={admin} />);
  expect(screen.getByText(/admin/i)).toBeInTheDocument();
});

When RTL Is the Right Tool (and When It Isn't)

RTL is my default for component and small integration tests: a form, a modal flow, a list that loads data and lets you filter it. Instead of asking "is the counter state at 1?", you ask "does the user see 'Count: 1'?", and that question still has the same answer after a refactor.

Where I'd reach for something else

  • Layout, scrolling, and anything visual: jsdom doesn't do layout. getBoundingClientRect() returns zeros, and toBeVisible() can't know an element is off screen or covered by another one. Use Playwright or Cypress for that.
  • Full flows across pages: Login, checkout, and redirects through a real backend belong in end-to-end tests. Faking the router, auth, and API all at once in RTL gets painful and proves less.
  • Pure logic: A date formatter or a reducer doesn't need a DOM. Test the function directly; it's faster and the failures are easier to read.
  • Browser APIs jsdom lacks: IntersectionObserver, ResizeObserver, and matchMedia aren't implemented. You'll have to stub them, and at that point you're testing your stub.
  • Huge snapshot tests: RTL works with snapshots, but a 400-line snapshot that everyone updates with -u without reading catches nothing.
"Write tests. Not too many. Mostly integration."
Guillermo Rauch

RTL covers the "mostly integration" part well, since you're rendering real components into a DOM and firing real event sequences. If you're adding it to an existing project, don't try to convert the whole suite. Pick the flow that breaks most often, write two or three tests for it with getByRole and userEvent, and see how they hold up through the next refactor.

React Testing RTL Jest Frontend Testing Library
Mayur Dabhi

Mayur Dabhi

Full Stack Developer with 5+ years of experience building scalable web applications with Laravel, React, and Node.js.