Testing

React Testing Library: Writing Better Tests

Mayur Dabhi
Mayur Dabhi
April 19, 2026
15 min read

Testing is the safety net that keeps production bugs from reaching your users. Yet most developers write tests that verify implementation details — which class names are applied, which internal state variable changed, which method was called. These tests break constantly during refactoring and give you false confidence. React Testing Library (RTL) was created specifically to fix this problem. Its guiding philosophy is deceptively simple: test your components the way users actually use them.

In this guide, we'll go deep on RTL — from installation and fundamental queries to advanced async patterns, mocking strategies, and the mental model that separates brittle tests from robust ones. By the end, you'll write tests that survive refactors, catch real bugs, and document how your UI is supposed to work.

The RTL Philosophy

RTL is built on a core principle coined by its creator Kent C. Dodds: "The more your tests resemble the way your software is used, the more confidence they can give you." This 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.

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

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

Queries: Finding Elements the Right Way

Queries are the heart of RTL. They determine how your test locates elements in the DOM — and choosing the right query type directly reflects how accessible your component is. RTL ships with three families of query functions, each with different behavior when the element isn't found.

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 exists immediately or asynchronously

Query Priority: Which to Use When

RTL recommends queries in this priority order — highest accessibility signal first:

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

Reaching for getByTestId first is a code smell. It means your component may not have the proper ARIA roles, labels, or visible text that real users and screen readers rely on. Only use it when no semantic query is possible.

Your First RTL Test

Let's write a real test for a Login component, step by step. First, the component itself:

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>
  );
}

Now the test file, using @testing-library/user-event for realistic user interactions:

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

Always prefer userEvent over fireEvent. fireEvent dispatches a single synthetic event. userEvent simulates the full sequence of events a real browser would fire — focus, keydown, input, keyup, change — making your tests far more realistic and reliable.

Async Testing Patterns

Real applications make API calls, use timers, and render data asynchronously. RTL provides several utilities to handle this correctly without flaky tests.

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:

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 actual HTTP requests at the network layer — the most realistic way to test API-driven components:

// 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/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 — 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 Execution Pipeline — No real browser required

How RTL, jsdom, and MSW work together to create a realistic test environment

Mocking Modules and Context

Mocking React Context

Components that consume Context need their providers wrapped during testing. The cleanest pattern is a custom render function:

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 extends Jest with DOM-aware matchers that produce far clearer failure messages than generic assertions:

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

Even experienced developers fall into these RTL anti-patterns. Here's what to watch out for:

Anti-pattern #1: Testing implementation details

Testing internal state, method calls on instances, or internal class names. When you refactor the internals — even without changing behavior — these tests break. Test what users see and interact with, not how it works under the hood.

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 automatically calls cleanup() after each test when used with a test framework like Jest — but if you have custom side effects (timers, subscriptions), clean those up in afterEach to prevent test pollution.

Structuring a Scalable Test Suite

As your test suite grows, organization becomes critical. Here's a battle-tested structure:

Recommended Test Organization

  • Co-locate tests: Keep Component.test.jsx next to Component.jsx — easy to find, easy to delete 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): More focused tests give clearer failure messages — but don't split single user flows into multiple tests
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();
});

Conclusion: Write Tests That Matter

React Testing Library fundamentally changes how you think about tests. Instead of asking "is the useState counter at 1?", you ask "can the user see 'Count: 1'?". This shift makes tests durable through refactors and genuinely useful for catching regressions.

Key Takeaways

  • Query priority matters: Use getByRole first — it also validates accessibility
  • Use userEvent over fireEvent: Realistic event simulation catches more bugs
  • Prefer findBy for async: It waits automatically without arbitrary delays
  • Mock at the right layer: MSW for API calls, jest.mock for modules, custom renders for context
  • Don't test implementation details: Tests should survive refactoring
  • Custom render utilities pay off: Invest once in a good test-utils.jsx and all your tests benefit
  • Co-locate tests: Tests near their component are tests that actually get maintained
"Write tests. Not too many. Mostly integration."
— Guillermo Rauch

React Testing Library makes the "mostly integration" part easy — you're testing real components rendering real DOM, responding to real events. Start with the user flow that matters most, then build from there. A small suite of high-confidence RTL tests beats a thousand shallow snapshot tests every time.

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.