Skip to content
C Codeloom
React

React Testing Library Best Practices

Practical guidance for writing maintainable, user-focused tests with React Testing Library, including queries, async patterns, and smart mocking.

·4 min read · By Codeloom
Intermediate 9 min read

What you'll learn

  • Pick the right queries for resilient tests
  • Test behavior, not implementation details
  • Handle async UI properly with findBy and waitFor
  • Mock smartly without hiding bugs
  • Structure tests for long-term maintenance

Prerequisites

  • Comfortable with JS
  • Some React experience

What and Why

React Testing Library (RTL) encourages tests that resemble how users interact with your app. Instead of poking at component internals, you query the DOM the way an assistive tech would, then assert on what the user sees. The result is tests that survive refactors and catch real regressions.

The library is small, but the way you use it determines whether your suite becomes an asset or a liability. The biggest difference between teams that love RTL and teams that fight it is query discipline.

Mental Model

Think of your test as a person using your app. They do not know about props, state, or context. They know labels, buttons, text, and roles. If a query reads like a UI script (“click the Save button”), it is probably resilient. If it reads like internals (“find the second div with class header__title”), it will break the moment you restructure markup.

Most preferred
getByRole / getByLabelText
getByPlaceholderText / getByText
getByDisplayValue / getByAltText
getByTitle
getByTestId
Least preferred (escape hatch)
Query priority pyramid

Hands-on Example

Suppose you have a login form. A user-centric test focuses on filling fields and submitting, then asserts on visible feedback.

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

test('shows error on empty submit', async () => {
  const user = userEvent.setup();
  render(<LoginForm onSubmit={jest.fn()} />);

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

  expect(
    await screen.findByText(/email is required/i)
  ).toBeInTheDocument();
});

Notice three things. We use userEvent not fireEvent, because it simulates real interactions including focus and keyboard. We query by role with an accessible name, which is the gold standard. We use findByText for the error, since validation may run after a tick.

render -> user.click -> validation -> DOM updates
               |
               v
 findByText polls until element appears
               |
               v
       assertion succeeds
Async query flow

For a happy path, you want to assert the submit handler was called with the right values, not that some internal setter ran.

test('submits credentials', async () => {
  const onSubmit = jest.fn();
  const user = userEvent.setup();
  render(<LoginForm onSubmit={onSubmit} />);

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

  expect(onSubmit).toHaveBeenCalledWith({
    email: 'a@b.com',
    password: 'hunter2',
  });
});

Common Pitfalls

Using getByTestId everywhere is the most common trap. Test IDs do not catch accessibility issues and tend to encourage tests tied to your DOM tree. Reserve them for cases where no semantic role exists.

Sleeping with setTimeout to wait for async updates is brittle. Prefer findBy* or waitFor, which poll until the condition holds or the timeout expires.

Snapshot tests of large trees rarely fail for good reasons. They mostly trip on unrelated changes and get blindly updated. If you snapshot, keep it small and intentional.

Mocking too aggressively hides real behavior. If you mock your API client to return a perfectly shaped response, you never test the loading state or error path. Prefer MSW (Mock Service Worker) at the network boundary so your components run their real fetching logic.

Finally, querying with regex but forgetting case sensitivity, or asserting on text that changes with i18n, leads to flaky tests. Use accessible names from labels that you control.

Best Practices

Prefer role-based queries. Roles map to ARIA semantics and force you to build accessible UIs. If you cannot find an element by role, that is often a real accessibility bug worth fixing.

Use userEvent.setup() per test. It returns a fresh instance and supports modern interaction features. Avoid the legacy direct calls.

Co-locate tests with components. A Button.test.tsx next to Button.tsx makes maintenance obvious and discoverable.

Test behavior in groups. Group “renders”, “interacts”, and “errors” with clear describe blocks. New contributors should grasp coverage at a glance.

Lean on MSW for network. Define handlers per scenario and reuse them across tests. This also makes your dev environment more realistic.

Keep tests fast. If a single test takes seconds, you will run them less. Mock timers, avoid unnecessary renders, and split heavy integration tests from unit tests.

Wrap-up

RTL rewards tests that focus on user intent. Pick semantic queries, embrace async helpers, mock at the network boundary, and avoid leaning on test IDs. With these habits, your suite catches regressions, encourages accessible markup, and survives refactors with minimal churn. Start by auditing one component test file today, and replace the first getByTestId you find with a role query.