Testing
React Testing Library, user-event, mocking, accessibility testing
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.