Skip to main content

Testing

Good tests document code behavior and serve as a safety net during refactoring. React Testing Library recommends testing from the user's perspective rather than the implementation.


React Testing Library Philosophy

The core principle of React Testing Library (RTL) is "Test behavior, not implementation".

// Bad test (testing implementation details)
test('state is updated', () => {
const { result } = renderHook(() => useCounter());
act(() => result.current.increment());
expect(result.current.count).toBe(1); // Inspecting internal state
});

// Good test (testing from user's perspective)
test('count increases when button is clicked', async () => {
render(<Counter />);
const button = screen.getByRole('button', { name: 'Increment' });
await userEvent.click(button);
expect(screen.getByText('1')).toBeInTheDocument();
});

// Testing principles:
// 1. Find elements by role, label, or text rather than direct DOM nodes
// 2. Test what the user sees, not implementation details (state, methods)
// 3. Prefer integration tests over unit tests

render and screen

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

function Greeting({ name, isLoggedIn }) {
if (!isLoggedIn) return <div>Login required</div>;
return (
<div>
<h1>Hello, {name}!</h1>
<p role="status">Logged in</p>
</div>
);
}

test('shows greeting message to logged-in user', () => {
render(<Greeting name="Alice" isLoggedIn={true} />);

// getBy: throws error if not found (test fails)
expect(screen.getByText('Hello, Alice!')).toBeInTheDocument();
expect(screen.getByRole('heading', { level: 1 })).toHaveTextContent('Alice');
expect(screen.getByRole('status')).toBeInTheDocument();
});

test('shows login required message when not logged in', () => {
render(<Greeting name="Alice" isLoggedIn={false} />);

expect(screen.getByText('Login required')).toBeInTheDocument();
// queryBy: returns null if not found (no error)
expect(screen.queryByRole('heading')).not.toBeInTheDocument();
});

Element Query Priority

// RTL recommended query priority (by accessibility)

// Priority 1: Role - most recommended
screen.getByRole('button', { name: 'Submit' });
screen.getByRole('textbox', { name: 'Email' });
screen.getByRole('heading', { level: 2 });
screen.getByRole('link', { name: 'Learn more' });
screen.getByRole('checkbox', { name: 'Agree' });

// Priority 2: Label text
screen.getByLabelText('Email address');

// Priority 3: Placeholder
screen.getByPlaceholderText('Enter your email');

// Priority 4: Text
screen.getByText('Save');
screen.getByText(/save/i); // Regex (case insensitive)

// Priority 5: Display value
screen.getByDisplayValue('Alice');

// Priority 6: Alt text (images)
screen.getByAltText('Profile photo');

// Priority 7: Title attribute
screen.getByTitle('Refresh');

// Priority 8: Test ID (last resort)
screen.getByTestId('submit-button');

// Query for multiple results
const listItems = screen.getAllByRole('listitem');
expect(listItems).toHaveLength(3);

userEvent Basic Patterns

import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';

function LoginForm({ onSubmit }) {
return (
<form onSubmit={onSubmit}>
<label htmlFor="email">Email</label>
<input id="email" name="email" type="email" />

<label htmlFor="password">Password</label>
<input id="password" name="password" type="password" />

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

describe('LoginForm', () => {
// Recommended to use setup() for userEvent (v14+)
const user = userEvent.setup();

test('submit with valid email and password', async () => {
const mockSubmit = jest.fn((e) => e.preventDefault());
render(<LoginForm onSubmit={mockSubmit} />);

// Click
await user.click(screen.getByLabelText('Email'));

// Type
await user.type(screen.getByLabelText('Email'), 'alice@example.com');
await user.type(screen.getByLabelText('Password'), 'password123');

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

expect(mockSubmit).toHaveBeenCalledTimes(1);
});

test('clear input', async () => {
render(<LoginForm onSubmit={jest.fn()} />);
const emailInput = screen.getByLabelText('Email');

await user.type(emailInput, 'test@example.com');
await user.clear(emailInput);

expect(emailInput).toHaveValue('');
});

test('keyboard shortcuts', async () => {
render(<LoginForm onSubmit={jest.fn()} />);

await user.keyboard('{Tab}'); // Tab key
await user.keyboard('{Enter}'); // Enter key
await user.keyboard('[Space]'); // Space key
await user.keyboard('{ctrl>}a{/ctrl}'); // Ctrl+A
});

test('select option from select', async () => {
render(
<select aria-label="Language">
<option value="ko">Korean</option>
<option value="en">English</option>
</select>
);

await user.selectOptions(screen.getByRole('combobox'), 'en');
expect(screen.getByDisplayValue('English')).toBeInTheDocument();
});
});

Jest Basics

// describe: group tests
describe('Calculator', () => {
// it (= test): individual test
it('can add two numbers', () => {
expect(add(2, 3)).toBe(5);
});

// Matchers
test('various matcher examples', () => {
// Equality
expect(1 + 1).toBe(2); // Strict equality (===)
expect({ a: 1 }).toEqual({ a: 1 }); // Deep equality

// Truthiness
expect(true).toBeTruthy();
expect(null).toBeFalsy();
expect(undefined).toBeUndefined();
expect(null).toBeNull();

// Numbers
expect(0.1 + 0.2).toBeCloseTo(0.3);
expect(5).toBeGreaterThan(3);
expect(2).toBeLessThanOrEqual(2);

// Strings
expect('Hello World').toMatch(/world/i);
expect('Hello').toContain('ell');

// Arrays
expect([1, 2, 3]).toContain(2);
expect([1, 2, 3]).toHaveLength(3);

// DOM (jest-dom)
expect(element).toBeInTheDocument();
expect(input).toHaveValue('Alice');
expect(button).toBeDisabled();
expect(div).toHaveClass('active');
expect(link).toHaveAttribute('href', '/home');
});
});

Async Tests

// async/await
test('fetches data', async () => {
const data = await fetchData();
expect(data.name).toBe('Alice');
});

// waitFor: wait for async DOM updates
import { render, screen, waitFor } from '@testing-library/react';

test('shows data after loading', async () => {
render(<AsyncComponent />);

// Verify loading state
expect(screen.getByText('Loading...')).toBeInTheDocument();

// Wait until data appears
await waitFor(() => {
expect(screen.getByText('Alice')).toBeInTheDocument();
});

// Verify loading disappears
expect(screen.queryByText('Loading...')).not.toBeInTheDocument();
});

// findBy: combination of waitFor + getBy (recommended)
test('shows result after button click', async () => {
const user = userEvent.setup();
render(<SearchComponent />);

await user.type(screen.getByRole('searchbox'), 'Alice');
await user.click(screen.getByRole('button', { name: 'Search' }));

// findBy automatically waits
const result = await screen.findByText('Alice (age 30)');
expect(result).toBeInTheDocument();
});

Using Mocks

// jest.fn(): mock function
const mockFn = jest.fn();
mockFn('hello');
mockFn(42);

expect(mockFn).toHaveBeenCalledTimes(2);
expect(mockFn).toHaveBeenCalledWith('hello');
expect(mockFn).toHaveBeenLastCalledWith(42);

// Set return values
mockFn.mockReturnValue(42);
mockFn.mockReturnValueOnce('first'); // Only once
mockFn.mockResolvedValue({ data: 'success' }); // Promise
mockFn.mockRejectedValueOnce(new Error('failed'));

// jest.spyOn: spy on existing methods
const spy = jest.spyOn(console, 'error').mockImplementation(() => {});
// Restore after test
spy.mockRestore();

// jest.mock: mock entire module
jest.mock('../api', () => ({
fetchUser: jest.fn().mockResolvedValue({ id: 1, name: 'Alice' }),
createUser: jest.fn().mockResolvedValue({ id: 2, name: 'Bob' })
}));

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

test('loads user data', async () => {
const user = await fetchUser(1);
expect(user.name).toBe('Alice');
expect(fetchUser).toHaveBeenCalledWith(1);
});

API Mocking with MSW (Mock Service Worker)

MSW is an API mocking tool that intercepts actual HTTP requests using service workers. It allows mocking real network requests in tests without modifying production code.

// src/mocks/handlers.js
import { http, HttpResponse } from 'msw';

export const handlers = [
// Handle GET requests
http.get('/api/users', () => {
return HttpResponse.json([
{ id: 1, name: 'Alice', email: 'alice@example.com' },
{ id: 2, name: 'Bob', email: 'bob@example.com' },
]);
}),

// URL parameters
http.get('/api/users/:id', ({ params }) => {
const { id } = params;
const users = { '1': { id: 1, name: 'Alice' }, '2': { id: 2, name: 'Bob' } };
const user = users[id];

if (!user) {
return new HttpResponse(null, { status: 404 });
}

return HttpResponse.json(user);
}),

// Handle POST requests
http.post('/api/users', async ({ request }) => {
const body = await request.json();

return HttpResponse.json(
{ id: 3, ...body },
{ status: 201 }
);
}),

// Simulate errors
http.get('/api/unstable', () => {
return new HttpResponse(null, { status: 500 });
}),
];
// src/mocks/server.js (for testing)
import { setupServer } from 'msw/node';
import { handlers } from './handlers';

export const server = setupServer(...handlers);
// setupTests.js (Jest configuration)
import { server } from './mocks/server';
import '@testing-library/jest-dom';

// Server setup before and after each test
beforeAll(() => server.listen({ onUnhandledRequest: 'error' }));
afterEach(() => server.resetHandlers());
afterAll(() => server.close());
// Using MSW in tests
import { render, screen } from '@testing-library/react';
import { server } from '../mocks/server';
import { http, HttpResponse } from 'msw';
import UserList from '../components/UserList';

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

// MSW automatically handles /api/users request
const users = await screen.findAllByRole('listitem');
expect(users).toHaveLength(2);
expect(screen.getByText('Alice')).toBeInTheDocument();
});

test('handles error state', async () => {
// Override with error response only for this specific test
server.use(
http.get('/api/users', () => {
return new HttpResponse(null, { status: 500 });
})
);

render(<UserList />);

const error = await screen.findByText('An error occurred');
expect(error).toBeInTheDocument();
});

Testing Custom Hooks

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

// Custom hook to test
function useCounter(initialValue = 0) {
const [count, setCount] = useState(initialValue);

const increment = useCallback(() => setCount(c => c + 1), []);
const decrement = useCallback(() => setCount(c => c - 1), []);
const reset = useCallback(() => setCount(initialValue), [initialValue]);

return { count, increment, decrement, reset };
}

// Custom hook tests
describe('useCounter', () => {
test('initial value is 0', () => {
const { result } = renderHook(() => useCounter());
expect(result.current.count).toBe(0);
});

test('increment increases the count', () => {
const { result } = renderHook(() => useCounter());

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

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

test('can specify initial value', () => {
const { result } = renderHook(() => useCounter(10));
expect(result.current.count).toBe(10);
});

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

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

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

// Testing custom hook that makes API calls (using MSW)
function useUser(id) {
const [user, setUser] = useState(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);

useEffect(() => {
fetch(`/api/users/${id}`)
.then(r => r.json())
.then(data => { setUser(data); setLoading(false); })
.catch(err => { setError(err.message); setLoading(false); });
}, [id]);

return { user, loading, error };
}

test('useUser loads user data', async () => {
const { result } = renderHook(() => useUser(1));

// Initial state: loading
expect(result.current.loading).toBe(true);
expect(result.current.user).toBe(null);

// Wait for data to load
await waitFor(() => {
expect(result.current.loading).toBe(false);
});

expect(result.current.user.name).toBe('Alice');
expect(result.current.error).toBe(null);
});

Provider Testing

// Testing components that use context
import { ThemeProvider } from '../contexts/ThemeContext';

// Custom render wrapper
function renderWithProviders(ui, { theme = 'light', user = null } = {}) {
function Wrapper({ children }) {
return (
<ThemeProvider initialTheme={theme}>
<AuthProvider initialUser={user}>
{children}
</AuthProvider>
</ThemeProvider>
);
}

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

test('button style in dark theme', () => {
renderWithProviders(<ThemedButton />, { theme: 'dark' });
expect(screen.getByRole('button')).toHaveClass('btn-dark');
});

test('shows user info to logged-in user', () => {
renderWithProviders(<UserMenu />, {
user: { name: 'Alice', role: 'admin' }
});
expect(screen.getByText('Alice')).toBeInTheDocument();
expect(screen.getByText('Admin')).toBeInTheDocument();
});

Pro Tips

1. Test File Structure Rules

src/
├── components/
│ ├── Button.jsx
│ └── Button.test.jsx ← Recommended in same location
├── hooks/
│ ├── useCounter.js
│ └── useCounter.test.js
└── __tests__/ ← Or separate directory
└── integration/
└── checkout.test.jsx

2. Common Test Utilities

// test-utils.jsx
import { render } from '@testing-library/react';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { MemoryRouter } from 'react-router-dom';

export function renderWithAll(ui, options = {}) {
const queryClient = new QueryClient({
defaultOptions: { queries: { retry: false } }
});

function AllProviders({ children }) {
return (
<QueryClientProvider client={queryClient}>
<MemoryRouter initialEntries={[options.initialPath ?? '/']}>
{children}
</MemoryRouter>
</QueryClientProvider>
);
}

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

// Use in all tests
// import { renderWithAll } from '../test-utils';

3. Snapshot Test Caveats

// Snapshot tests are useful for detecting changes but avoid overuse
test('button snapshot', () => {
const { container } = render(<Button variant="primary">Submit</Button>);
expect(container).toMatchSnapshot();
// Snapshot is created on first run, fails on subsequent changes
});

// Better approach: specific assertions
test('button renders', () => {
render(<Button variant="primary">Submit</Button>);
const button = screen.getByRole('button', { name: 'Submit' });
expect(button).toHaveClass('btn-primary');
expect(button).not.toBeDisabled();
});