React Testing Library (RTL) tests components from the user's perspective — querying by accessible roles, labels, and text rather than implementation details. Combined with Jest (or Vitest), MSW for API mocking, and Playwright for E2E, it provides a complete testing strategy for React applications.

Key Points

  • React Testing Library: render component → query DOM → interact → assert — no internal state/lifecycle access
  • Queries priority: getByRole (most accessible) > getByLabelText > getByText > getByTestId (last resort)
  • getBy (throws if not found), queryBy (returns null if not found), findBy (async, waits)
  • userEvent (v14): simulates real browser events — userEvent.type(), click(), tab() — more realistic than fireEvent
  • waitFor: assert on async state changes — waitFor(() => expect(screen.getByText("Loaded")).toBeInTheDocument())
  • MSW (Mock Service Worker): intercepts real fetch/XHR in tests — define handlers once, reuse in tests and browser
  • Testing custom hooks: renderHook() from RTL — test hooks in isolation without a consuming component
  • screen.debug(): prints current DOM — essential for debugging failing tests
  • Vitest: Vite-native test runner — 100x faster than Jest for Vite/Next.js projects due to native ESM

React Testing Library: MSW server, findBy async queries, userEvent interactions, custom hook with renderHook

import { render, screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { http, HttpResponse } from 'msw';
import { setupServer } from 'msw/node';

// MSW — intercept API calls
const server = setupServer(
  http.get('/api/users', () => HttpResponse.json([
    { id: 1, name: 'Alice', email: 'alice@test.com' }
  ])),
  http.post('/api/users', async ({ request }) => {
    const body = await request.json() as { name: string };
    return HttpResponse.json({ id: 2, ...body }, { status: 201 });
  })
);

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

// Component test — user perspective
describe('UserList', () => {
  it('displays users loaded from API', async () => {
    render(<UserList />);

    // Loading state
    expect(screen.getByRole('progressbar')).toBeInTheDocument();

    // After data loads
    await screen.findByText('Alice');      // findBy waits automatically
    expect(screen.getByRole('cell', { name: 'alice@test.com' })).toBeInTheDocument();
  });

  it('adds a new user via form', async () => {
    const user = userEvent.setup();
    render(<UserList />);
    await screen.findByText('Alice');

    await user.click(screen.getByRole('button', { name: 'Add User' }));
    await user.type(screen.getByLabelText('Name'), 'Bob');
    await user.type(screen.getByLabelText('Email'), 'bob@test.com');
    await user.click(screen.getByRole('button', { name: 'Save' }));

    await waitFor(() => expect(screen.getByText('User added successfully')).toBeInTheDocument());
  });

  it('shows error when API fails', async () => {
    server.use(
      http.get('/api/users', () => HttpResponse.error())   // override handler
    );
    render(<UserList />);
    await screen.findByText(/failed to load/i);
  });
});

// Custom hook test
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);
});

Real-World Example

MSW is a paradigm shift for API mocking — the same handlers work in both Jest/Vitest tests (via msw/node) and in the browser (via Service Worker) for development without a backend. This means your mock API contract is tested and consistent across environments. Testing by accessible role (getByRole) also drives you to write semantically correct HTML — tests break if you use a <div> instead of a <button>, which improves accessibility.