Advanced25 min read

Testing React Components

Understand the philosophy and tools behind testing React components, including React Testing Library, user-centric queries, and best practices for writing maintainable tests.

Why Test React Components?

Testing is your safety net. Without tests, every change you make to your codebase is a gamble — you might fix one thing and break three others without knowing. Tests give you confidence that your components behave correctly and continue to work as you refactor, add features, or upgrade dependencies.

There are three main levels of testing:

1. Unit tests — Test individual functions or components in isolation. Fast to run, easy to write, but can miss integration issues.

javascript
// Testing a utility function
expect(formatPrice(1999)).toBe('$19.99');

2. Integration tests — Test how multiple components work together. These are the sweet spot for React — testing a form component that includes input fields, validation, and submission.

javascript
// Testing a login form
render(<LoginForm />);
await user.type(screen.getByLabelText('Email'), 'test@example.com');
await user.click(screen.getByRole('button', { name: 'Log in' }));
expect(screen.getByText('Welcome!')).toBeInTheDocument();

3. End-to-end (E2E) tests — Test the entire application from the user's perspective using tools like Playwright or Cypress. Slow and expensive, but catch real-world issues.

The testing trophy (popularized by Kent C. Dodds) suggests writing mostly integration tests, some unit tests, fewer E2E tests, and minimal static analysis. This gives the best ratio of confidence to effort.

html
    /\      E2E (few)
   /  \     Integration (many)
  /    \    Unit (some)
 /------\   Static (TypeScript, ESLint)

React Testing Library Philosophy

React Testing Library (RTL) is the standard tool for testing React components. It is built on a simple principle:

"The more your tests resemble the way your software is used, the more confidence they can give you."

This means testing behavior, not implementation. You should not test whether a state variable was set to true or whether a specific function was called. Instead, test what the user sees and does:

  • Does the button text change after clicking?
  • Does the error message appear for invalid input?
  • Is the list of items visible after loading?

Bad test (implementation detail):

javascript
// Testing internal state — fragile, breaks during refactoring
const { result } = renderHook(() => useCounter());
act(() => result.current.increment());
expect(result.current.count).toBe(1);

Good test (behavior):

javascript
// Testing what the user sees — resilient to refactoring
render(<Counter />);
await user.click(screen.getByRole('button', { name: 'Increment' }));
expect(screen.getByText('Count: 1')).toBeInTheDocument();

Both tests verify the counter works, but the behavior test will survive refactoring (renaming the hook, changing state management) while the implementation test will break.

RTL provides a render function that mounts your component into a test DOM, and a screen object to query what was rendered. It intentionally does not expose component internals like state or props.

Querying the DOM in Tests

React Testing Library provides several query methods, prioritized by how accessible they are:

Priority 1: Accessible to everyone

  • getByRole('button') — Queries by ARIA role. Best choice because it matches how screen readers and users perceive elements.
  • getByLabelText('Email') — Queries form elements by their associated label. Great for form fields.
  • getByPlaceholderText('Search...') — Queries by placeholder. Use only if there is no label.
  • getByText('Submit') — Queries by visible text content.
  • getByDisplayValue('john@example.com') — Queries by the current value of an input.

Priority 2: Semantic queries

  • getByAltText('User avatar') — Queries images by alt text.
  • getByTitle('Close') — Queries by the title attribute.

Priority 3: Test IDs (last resort)

  • getByTestId('submit-button') — Queries by data-testid attribute. Use when no other query works.
jsx
// Component
function UserCard({ name, role }) {
  return (
    <div data-testid="user-card">
      <h3>{name}</h3>
      <p>{role}</p>
    </div>
  );
}

// Test
render(<UserCard name="Jane" role="Developer" />);
expect(screen.getByText('Jane')).toBeInTheDocument();
expect(screen.getByText('Developer')).toBeInTheDocument();
expect(screen.getByTestId('user-card')).toBeInTheDocument();

Each getBy query throws an error if the element is not found. Use queryBy when you expect the element not to exist (returns null instead of throwing). Use findBy for elements that appear asynchronously (returns a Promise).

javascript
// Assert something is NOT present
expect(screen.queryByText('Error')).not.toBeInTheDocument();

// Wait for async content
const message = await screen.findByText('Data loaded');

User Events and Assertions

React Testing Library encourages testing user interactions with the @testing-library/user-event library, which simulates real browser events more accurately than fireEvent:

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

test('form submission', async () => {
  const user = userEvent.setup();
  render(<LoginForm />);

  // Type into inputs
  await user.type(screen.getByLabelText('Email'), 'test@example.com');
  await user.type(screen.getByLabelText('Password'), 'secret123');

  // Click submit
  await user.click(screen.getByRole('button', { name: 'Log in' }));

  // Assert the result
  expect(screen.getByText('Welcome, test@example.com!')).toBeInTheDocument();
});

user.type() simulates each keystroke individually, triggering keydown, keypress, input, and keyup events — just like a real user typing. fireEvent.change() only triggers the change event, which can miss bugs.

Common assertions from @testing-library/jest-dom:

javascript
// Element presence
expect(element).toBeInTheDocument();
expect(element).not.toBeInTheDocument();

// Visibility
expect(element).toBeVisible();
expect(element).toHaveStyle('display: none');

// Content
expect(element).toHaveTextContent('Hello');
expect(input).toHaveValue('test@example.com');

// Attributes
expect(button).toBeDisabled();
expect(input).toBeRequired();
expect(element).toHaveAttribute('href', '/about');
expect(element).toHaveClass('active');

The data-testid attribute is your escape hatch for querying elements that have no accessible role, label, or text. It is not visible to users and does not affect behavior:

jsx
<div data-testid="user-card">
  <h3 data-testid="user-name">Jane</h3>
</div>

Use data-testid sparingly — prefer accessible queries first.

Mocking and Snapshot Testing

Mocking replaces real dependencies with controlled substitutes during tests. This is essential for isolating the component under test:

Mocking API calls:

javascript
// Mock the fetch function
global.fetch = jest.fn(() =>
  Promise.resolve({
    json: () => Promise.resolve({ name: 'Jane', role: 'Developer' }),
  })
);

test('displays user data', async () => {
  render(<UserProfile userId="1" />);
  expect(await screen.findByText('Jane')).toBeInTheDocument();
});

Mocking child components:

javascript
// Replace a heavy child component with a simple mock
jest.mock('./HeavyChart', () => {
  return function MockChart() {
    return <div data-testid="chart">Chart placeholder</div>;
  };
});

Mocking context providers:

javascript
function renderWithTheme(ui, theme = 'light') {
  return render(
    <ThemeContext.Provider value={{ theme }}>
      {ui}
    </ThemeContext.Provider>
  );
}

test('renders dark theme', () => {
  renderWithTheme(<Header />, 'dark');
  expect(screen.getByRole('banner')).toHaveClass('dark');
});

Snapshot testing captures the rendered output of a component and compares it to a stored "snapshot" file. If the output changes, the test fails:

javascript
test('matches snapshot', () => {
  const { container } = render(<UserCard name="Jane" role="Dev" />);
  expect(container).toMatchSnapshot();
});

Snapshots are controversial. They are easy to create but can lead to mindless "update snapshot" acceptance when they break. Use them for stable, presentational components and prefer explicit assertions for interactive components.

Best practice: Write tests that would fail if the behavior changed but pass if only the implementation changed. This is the key insight behind React Testing Library.

Testable Component Example

html
<div id="root"></div>
<script src="https://unpkg.com/react@19/umd/react.development.js"></script>
<script src="https://unpkg.com/react-dom@19/umd/react-dom.development.js"></script>
<script src="https://unpkg.com/@babel/standalone/babel.min.js"></script>
<script type="text/babel">
  // A well-structured, testable component
  // Notice: semantic HTML, data-testid for complex queries, props for data

  function UserCard({ name, role }) {
    return (
      <div data-testid="user-card">
        <h3 data-testid="user-name">{name}</h3>
        <p data-testid="user-role">{role}</p>
      </div>
    );
  }

  function UserList({ users }) {
    if (users.length === 0) {
      return <p data-testid="empty-message">No users found.</p>;
    }
    return (
      <div data-testid="user-list">
        {users.map((user, i) => (
          <UserCard key={i} name={user.name} role={user.role} />
        ))}
      </div>
    );
  }

  function App() {
    const users = [
      { name: "Jane", role: "Developer" },
      { name: "Alex", role: "Designer" },
    ];
    return (
      <div>
        <h1>Team Members</h1>
        <UserList users={users} />
      </div>
    );
  }

  ReactDOM.createRoot(document.getElementById('root')).render(<App />);
</script>

Which query should you prefer first when testing a button with React Testing Library?

Ready to practice?

Create your free account to access the interactive code editor, run challenges, and track your progress.