Skip to content
Codeloom
React

React Testing Library: Complete Testing Guide

Learn to test React components with React Testing Library. Covers rendering, user events, async testing, mocking, and accessibility-driven queries.

·7 min read · By Codeloom
Intermediate 16 min read

What you'll learn

  • Setting up React Testing Library with Vitest
  • Writing tests using accessibility-first queries
  • Testing user interactions, forms, and async behavior
  • Mocking API calls and modules

Prerequisites

  • React components and hooks
  • Basic JavaScript testing concepts

React Testing Library encourages you to test components the way users interact with them. Instead of testing implementation details like state values or method calls, you test what the user sees and does. This produces tests that are more resilient to refactoring and more meaningful as documentation.

Setup

Install the necessary packages with your preferred test runner. This guide uses Vitest, but Jest works the same way:

npm install -D @testing-library/react @testing-library/jest-dom @testing-library/user-event vitest jsdom

Configure Vitest in vite.config.ts:

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

export default defineConfig({
  plugins: [react()],
  test: {
    environment: 'jsdom',
    globals: true,
    setupFiles: './src/test/setup.ts',
  },
});

Create the setup file at src/test/setup.ts:

import '@testing-library/jest-dom/vitest';

This adds custom matchers like toBeInTheDocument() and toHaveTextContent().

Your First Test

Start with a simple component:

function Greeting({ name }: { name: string }) {
  return <h1>Hello, {name}!</h1>;
}

Test it by rendering and querying:

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

test('displays greeting with name', () => {
  render(<Greeting name="Alice" />);

  expect(screen.getByRole('heading')).toHaveTextContent('Hello, Alice!');
});

screen.getByRole('heading') finds the element by its accessibility role, not by a CSS class or test ID. This is the core philosophy: query like a user or assistive technology would.

Query Priority

React Testing Library provides several query types. Use them in this priority order:

  1. getByRole - Queries by ARIA role. Works for buttons, headings, textboxes, etc.
  2. getByLabelText - Finds form elements by their label. Best for inputs.
  3. getByPlaceholderText - Fallback for inputs without labels.
  4. getByText - Finds elements by their text content.
  5. getByDisplayValue - Finds inputs by their current value.
  6. getByAltText - Finds images by alt text.
  7. getByTestId - Last resort. Only use when no other query works.
// Preferred: query by role
screen.getByRole('button', { name: 'Submit' });

// Good: query by label
screen.getByLabelText('Email address');

// Acceptable: query by text
screen.getByText('Welcome back');

// Last resort: query by test ID
screen.getByTestId('custom-dropdown');

Testing User Interactions

Use @testing-library/user-event for realistic user interactions. It simulates actual browser events more accurately than fireEvent:

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

function Counter() {
  const [count, setCount] = useState(0);

  return (
    <div>
      <span>Count: {count}</span>
      <button onClick={() => setCount(c => c + 1)}>Increment</button>
      <button onClick={() => setCount(c => c - 1)}>Decrement</button>
      <button onClick={() => setCount(0)}>Reset</button>
    </div>
  );
}

test('increments and decrements count', async () => {
  const user = userEvent.setup();
  render(<Counter />);

  expect(screen.getByText('Count: 0')).toBeInTheDocument();

  await user.click(screen.getByRole('button', { name: 'Increment' }));
  expect(screen.getByText('Count: 1')).toBeInTheDocument();

  await user.click(screen.getByRole('button', { name: 'Increment' }));
  expect(screen.getByText('Count: 2')).toBeInTheDocument();

  await user.click(screen.getByRole('button', { name: 'Decrement' }));
  expect(screen.getByText('Count: 1')).toBeInTheDocument();

  await user.click(screen.getByRole('button', { name: 'Reset' }));
  expect(screen.getByText('Count: 0')).toBeInTheDocument();
});

Testing Forms

Forms are where React Testing Library shines. Test the complete user flow:

function LoginForm({ onSubmit }: { onSubmit: (data: LoginData) => void }) {
  const [email, setEmail] = useState('');
  const [password, setPassword] = useState('');
  const [error, setError] = useState('');

  function handleSubmit(e: React.FormEvent) {
    e.preventDefault();
    if (!email || !password) {
      setError('All fields are required');
      return;
    }
    onSubmit({ email, password });
  }

  return (
    <form onSubmit={handleSubmit}>
      <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)}
      />

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

      <button type="submit">Log in</button>
    </form>
  );
}

describe('LoginForm', () => {
  test('submits with valid data', async () => {
    const user = userEvent.setup();
    const handleSubmit = vi.fn();
    render(<LoginForm onSubmit={handleSubmit} />);

    await user.type(screen.getByLabelText('Email'), 'alice@example.com');
    await user.type(screen.getByLabelText('Password'), 'securepass');
    await user.click(screen.getByRole('button', { name: 'Log in' }));

    expect(handleSubmit).toHaveBeenCalledWith({
      email: 'alice@example.com',
      password: 'securepass',
    });
  });

  test('shows error when fields are empty', async () => {
    const user = userEvent.setup();
    render(<LoginForm onSubmit={vi.fn()} />);

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

    expect(screen.getByRole('alert')).toHaveTextContent('All fields are required');
  });
});

Testing Async Behavior

Use findBy queries for elements that appear asynchronously. They automatically wait and retry:

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

  useEffect(() => {
    fetchUser(userId).then(data => {
      setUser(data);
      setLoading(false);
    });
  }, [userId]);

  if (loading) return <p>Loading...</p>;
  return <h2>{user.name}</h2>;
}

test('loads and displays user data', async () => {
  vi.mocked(fetchUser).mockResolvedValue({ name: 'Alice' });

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

  expect(screen.getByText('Loading...')).toBeInTheDocument();

  const heading = await screen.findByRole('heading');
  expect(heading).toHaveTextContent('Alice');
});

For waiting until elements disappear, use waitForElementToBeRemoved:

import { waitForElementToBeRemoved } from '@testing-library/react';

test('loading spinner disappears after fetch', async () => {
  render(<UserProfile userId="1" />);

  await waitForElementToBeRemoved(() => screen.queryByText('Loading...'));
  expect(screen.getByRole('heading')).toHaveTextContent('Alice');
});

Mocking API Calls

Use vi.mock to mock modules, or use MSW (Mock Service Worker) for more realistic API mocking:

Simple mocking with vi.mock:

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

vi.mock('../api', () => ({
  fetchUsers: vi.fn(),
}));

beforeEach(() => {
  vi.mocked(fetchUsers).mockResolvedValue([
    { id: 1, name: 'Alice' },
    { id: 2, name: 'Bob' },
  ]);
});

test('displays list of users', async () => {
  render(<UserList />);

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

MSW for API mocking:

import { http, HttpResponse } from 'msw';
import { setupServer } from 'msw/node';

const server = setupServer(
  http.get('/api/users', () => {
    return HttpResponse.json([
      { id: 1, name: 'Alice' },
      { id: 2, name: 'Bob' },
    ]);
  })
);

beforeAll(() => server.listen());
afterEach(() => server.resetHandlers());
afterAll(() => server.close());

test('handles server error', async () => {
  server.use(
    http.get('/api/users', () => {
      return new HttpResponse(null, { status: 500 });
    })
  );

  render(<UserList />);
  expect(await screen.findByText('Failed to load users')).toBeInTheDocument();
});

Testing Components with Context

Wrap components with their required providers using a custom render function:

import { render } from '@testing-library/react';
import { ThemeProvider } from '../context/ThemeContext';
import { AuthProvider } from '../context/AuthContext';

function renderWithProviders(ui: React.ReactElement, options = {}) {
  function Wrapper({ children }: { children: React.ReactNode }) {
    return (
      <AuthProvider>
        <ThemeProvider>
          {children}
        </ThemeProvider>
      </AuthProvider>
    );
  }

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

test('shows user name from auth context', () => {
  renderWithProviders(<UserBadge />);
  expect(screen.getByText('Alice')).toBeInTheDocument();
});

Testing Custom Hooks

Use renderHook from React Testing Library to test hooks in isolation:

import { renderHook, act } from '@testing-library/react';
import { useCounter } from './useCounter';

test('increments counter', () => {
  const { result } = renderHook(() => useCounter(0));

  expect(result.current.count).toBe(0);

  act(() => {
    result.current.increment();
  });

  expect(result.current.count).toBe(1);
});

test('resets counter to initial value', () => {
  const { result } = renderHook(() => useCounter(5));

  act(() => {
    result.current.increment();
    result.current.increment();
  });

  expect(result.current.count).toBe(7);

  act(() => {
    result.current.reset();
  });

  expect(result.current.count).toBe(5);
});

Common Mistakes

Using getBy for elements that might not exist:

// This throws if the element doesn't exist
screen.getByText('Error message'); // throws

// Use queryBy instead - returns null if not found
expect(screen.queryByText('Error message')).not.toBeInTheDocument();

Not waiting for async updates:

// Bad: might fail intermittently
test('loads data', () => {
  render(<AsyncComponent />);
  expect(screen.getByText('Loaded')).toBeInTheDocument(); // might not be there yet
});

// Good: wait for it
test('loads data', async () => {
  render(<AsyncComponent />);
  expect(await screen.findByText('Loaded')).toBeInTheDocument();
});

Testing implementation details:

// Bad: testing state directly
expect(component.state.isOpen).toBe(true);

// Good: testing what the user sees
expect(screen.getByRole('dialog')).toBeInTheDocument();

Wrapping Up

React Testing Library pushes you toward testing behavior rather than implementation. Use role-based queries to find elements the way users and screen readers do. Use userEvent for realistic interactions. Use findBy queries for async content. Mock at the API boundary with MSW for realistic integration tests. When your tests focus on what the user experiences, they stay valid even when you refactor the internal implementation of your components.