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();
});