Skip to content
Codeloom
Testing

Testing React Components with Testing Library

Learn to test React components the right way with React Testing Library. Covers rendering, queries, user events, async testing, and best practices.

·7 min read · By Codeloom
Intermediate 14 min read

What you'll learn

  • The philosophy behind React Testing Library
  • Rendering components and querying the DOM
  • Simulating user interactions with userEvent
  • Testing async behavior (API calls, loading states)
  • Common testing patterns and anti-patterns

Prerequisites

  • React basics (components, props, state, hooks)
  • JavaScript testing fundamentals
  • Basic familiarity with Jest or Vitest

React Testing Library encourages you to test components the way users interact with them — by finding elements by their text, labels, and roles, not by CSS selectors or component internals. This approach produces tests that are resilient to refactoring and actually verify that your UI works.

Setup

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

If using Vitest, add the setup:

// vitest.config.js
import { defineConfig } from 'vitest/config';
import react from '@vitejs/plugin-react';

export default defineConfig({
  plugins: [react()],
  test: {
    environment: 'jsdom',
    setupFiles: ['./src/test/setup.js'],
  },
});
// src/test/setup.js
import '@testing-library/jest-dom';

Your first test

// Greeting.jsx
export function Greeting({ name }) {
  return <h1>Hello, {name}!</h1>;
}
// Greeting.test.jsx
import { render, screen } from '@testing-library/react';
import { Greeting } from './Greeting';

test('renders greeting with name', () => {
  render(<Greeting name="Alice" />);
  expect(screen.getByText('Hello, Alice!')).toBeInTheDocument();
});

render mounts the component into a virtual DOM. screen provides query methods to find elements. getByText finds an element containing the specified text.

Query priority

React Testing Library provides multiple query methods. Use them in this priority order (most accessible first):

  1. getByRole — accessible roles (button, heading, textbox)
  2. getByLabelText — form elements by their label
  3. getByPlaceholderText — inputs by placeholder
  4. getByText — elements by their text content
  5. getByDisplayValue — inputs by their current value
  6. getByAltText — images by alt text
  7. getByTitle — elements by title attribute
  8. getByTestId — last resort, by data-testid attribute
// Good: tests what the user sees
screen.getByRole('button', { name: 'Submit' });
screen.getByLabelText('Email address');
screen.getByText('Welcome back');

// Avoid: tests implementation details
screen.getByTestId('submit-btn'); // Only as last resort

Query variants

Each query has three variants:

VariantNo match1 matchMultiple matches
getByThrowsReturns elementThrows
queryByReturns nullReturns elementThrows
findByRejectsResolvesRejects
getAllByThrowsReturns arrayReturns array
queryAllByReturns []Returns arrayReturns array
findAllByRejectsResolvesResolves

Use getBy for elements that should exist, queryBy to assert elements do NOT exist, and findBy for elements that appear asynchronously:

// Element should exist
const button = screen.getByRole('button', { name: 'Submit' });

// Element should NOT exist
expect(screen.queryByText('Error')).not.toBeInTheDocument();

// Element appears after async operation
const message = await screen.findByText('Success!');

User interactions with userEvent

userEvent simulates real user behavior more accurately than fireEvent:

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

test('form submission', async () => {
  const user = userEvent.setup();
  const onSubmit = vi.fn();

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

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

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

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

Common user interactions

const user = userEvent.setup();

// Click
await user.click(element);
await user.dblClick(element);

// Type
await user.type(input, 'hello');

// Clear and type
await user.clear(input);
await user.type(input, 'new value');

// Select from dropdown
await user.selectOptions(select, 'option-value');

// Check/uncheck
await user.click(checkbox);

// Tab navigation
await user.tab();

// Keyboard shortcuts
await user.keyboard('{Enter}');
await user.keyboard('{Shift>}{A}{/Shift}'); // Shift+A

// Hover
await user.hover(element);
await user.unhover(element);

Testing a complete form

// ContactForm.jsx
import { useState } from 'react';

export function ContactForm({ onSubmit }) {
  const [errors, setErrors] = useState({});

  const handleSubmit = (e) => {
    e.preventDefault();
    const formData = new FormData(e.target);
    const data = Object.fromEntries(formData);

    const newErrors = {};
    if (!data.name) newErrors.name = 'Name is required';
    if (!data.email) newErrors.email = 'Email is required';
    if (data.email && !data.email.includes('@')) newErrors.email = 'Invalid email';

    if (Object.keys(newErrors).length > 0) {
      setErrors(newErrors);
      return;
    }

    onSubmit(data);
  };

  return (
    <form onSubmit={handleSubmit}>
      <label>
        Name
        <input name="name" />
      </label>
      {errors.name && <span role="alert">{errors.name}</span>}

      <label>
        Email
        <input name="email" type="email" />
      </label>
      {errors.email && <span role="alert">{errors.email}</span>}

      <button type="submit">Send</button>
    </form>
  );
}
// ContactForm.test.jsx
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { ContactForm } from './ContactForm';

describe('ContactForm', () => {
  const user = userEvent.setup();

  test('submits with valid data', async () => {
    const onSubmit = vi.fn();
    render(<ContactForm onSubmit={onSubmit} />);

    await user.type(screen.getByLabelText('Name'), 'Alice');
    await user.type(screen.getByLabelText('Email'), 'alice@test.com');
    await user.click(screen.getByRole('button', { name: 'Send' }));

    expect(onSubmit).toHaveBeenCalledWith({
      name: 'Alice',
      email: 'alice@test.com',
    });
  });

  test('shows error for empty name', async () => {
    render(<ContactForm onSubmit={vi.fn()} />);

    await user.type(screen.getByLabelText('Email'), 'alice@test.com');
    await user.click(screen.getByRole('button', { name: 'Send' }));

    expect(screen.getByRole('alert')).toHaveTextContent('Name is required');
  });

  test('shows error for invalid email', async () => {
    render(<ContactForm onSubmit={vi.fn()} />);

    await user.type(screen.getByLabelText('Name'), 'Alice');
    await user.type(screen.getByLabelText('Email'), 'not-an-email');
    await user.click(screen.getByRole('button', { name: 'Send' }));

    expect(screen.getByRole('alert')).toHaveTextContent('Invalid email');
  });

  test('does not submit when validation fails', async () => {
    const onSubmit = vi.fn();
    render(<ContactForm onSubmit={onSubmit} />);

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

    expect(onSubmit).not.toHaveBeenCalled();
  });
});

Testing async behavior

Mocking API calls

// UserProfile.jsx
import { useState, useEffect } from 'react';

export function UserProfile({ userId }) {
  const [user, setUser] = useState(null);
  const [loading, setLoading] = useState(true);
  const [error, setError] = useState(null);

  useEffect(() => {
    fetch(`/api/users/${userId}`)
      .then((res) => {
        if (!res.ok) throw new Error('User not found');
        return res.json();
      })
      .then(setUser)
      .catch((err) => setError(err.message))
      .finally(() => setLoading(false));
  }, [userId]);

  if (loading) return <p>Loading...</p>;
  if (error) return <p role="alert">{error}</p>;

  return (
    <div>
      <h2>{user.name}</h2>
      <p>{user.email}</p>
    </div>
  );
}
// UserProfile.test.jsx
import { render, screen } from '@testing-library/react';
import { UserProfile } from './UserProfile';

// Mock fetch globally
beforeEach(() => {
  global.fetch = vi.fn();
});

test('shows loading state initially', () => {
  fetch.mockResolvedValueOnce({ ok: true, json: () => Promise.resolve({}) });
  render(<UserProfile userId="1" />);
  expect(screen.getByText('Loading...')).toBeInTheDocument();
});

test('displays user data after loading', async () => {
  fetch.mockResolvedValueOnce({
    ok: true,
    json: () => Promise.resolve({ name: 'Alice', email: 'alice@test.com' }),
  });

  render(<UserProfile userId="1" />);

  // findByText waits for the element to appear
  expect(await screen.findByText('Alice')).toBeInTheDocument();
  expect(screen.getByText('alice@test.com')).toBeInTheDocument();
  expect(screen.queryByText('Loading...')).not.toBeInTheDocument();
});

test('shows error message on failure', async () => {
  fetch.mockResolvedValueOnce({ ok: false });

  render(<UserProfile userId="999" />);

  expect(await screen.findByRole('alert')).toHaveTextContent('User not found');
});

Testing with context providers

// Wrap components that need providers
function renderWithProviders(ui, { theme = 'light', ...options } = {}) {
  function Wrapper({ children }) {
    return (
      <ThemeProvider theme={theme}>
        <AuthProvider>
          {children}
        </AuthProvider>
      </ThemeProvider>
    );
  }
  return render(ui, { wrapper: Wrapper, ...options });
}

// Usage in tests
test('renders with theme', () => {
  renderWithProviders(<ThemedButton />, { theme: 'dark' });
  expect(screen.getByRole('button')).toHaveClass('dark');
});

Common anti-patterns

Do not test implementation details

// Bad: testing state values
expect(component.state.count).toBe(5);

// Good: testing what the user sees
expect(screen.getByText('Count: 5')).toBeInTheDocument();

Do not use container queries

// Bad: querying by CSS selector
const { container } = render(<App />);
const button = container.querySelector('.submit-btn');

// Good: querying by role
const button = screen.getByRole('button', { name: 'Submit' });

Do not use snapshot tests for UI logic

// Bad: snapshot tests are brittle and don't test behavior
expect(container).toMatchSnapshot();

// Good: test specific behavior
expect(screen.getByRole('heading')).toHaveTextContent('Welcome');

Debugging tips

// Print the current DOM
screen.debug();

// Print a specific element
screen.debug(screen.getByRole('form'));

// Log accessible roles in the DOM
import { logRoles } from '@testing-library/react';
const { container } = render(<App />);
logRoles(container);

Summary

React Testing Library pushes you to test from the user’s perspective: find elements by roles, labels, and text, not by class names or test IDs. Use userEvent for realistic interaction simulation. Use findBy queries for async elements. Mock API calls at the fetch level. Create reusable render wrappers for context providers. The guiding principle is: if you cannot find an element using accessibility queries, your component likely has an accessibility problem to fix.