Testing Advanced Patterns
Advanced Testing Patterns in React
In this lesson, we'll explore advanced testing techniques including mocking API calls, testing custom hooks, integration testing, snapshot testing, and measuring code coverage. These patterns help you build comprehensive test suites for complex applications.
Mocking API Calls
When testing components that make API calls, you should mock the API to avoid actual network requests:
// todoService.js
export const fetchTodos = async () => {
const response = await fetch('/api/todos');
return response.json();
};
export const createTodo = async (todo) => {
const response = await fetch('/api/todos', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(todo)
});
return response.json();
};
// TodoList.test.jsx
import { render, screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import TodoList from './TodoList';
import * as todoService from './todoService';
// Mock the entire module
jest.mock('./todoService');
describe('TodoList Component', () => {
beforeEach(() => {
jest.clearAllMocks();
});
test('loads and displays todos', async () => {
const mockTodos = [
{ id: 1, title: 'Buy groceries', completed: false },
{ id: 2, title: 'Walk the dog', completed: true }
];
todoService.fetchTodos.mockResolvedValue(mockTodos);
render(<TodoList />);
await waitFor(() => {
expect(screen.getByText('Buy groceries')).toBeInTheDocument();
expect(screen.getByText('Walk the dog')).toBeInTheDocument();
});
expect(todoService.fetchTodos).toHaveBeenCalledTimes(1);
});
test('creates a new todo', async () => {
const user = userEvent.setup();
const newTodo = { id: 3, title: 'New task', completed: false };
todoService.fetchTodos.mockResolvedValue([]);
todoService.createTodo.mockResolvedValue(newTodo);
render(<TodoList />);
const input = screen.getByPlaceholderText(/add todo/i);
const addButton = screen.getByRole('button', { name: /add/i });
await user.type(input, 'New task');
await user.click(addButton);
await waitFor(() => {
expect(screen.getByText('New task')).toBeInTheDocument();
});
expect(todoService.createTodo).toHaveBeenCalledWith({
title: 'New task',
completed: false
});
});
test('handles API errors gracefully', async () => {
todoService.fetchTodos.mockRejectedValue(
new Error('Failed to fetch todos')
);
render(<TodoList />);
const errorMessage = await screen.findByText(/failed to fetch/i);
expect(errorMessage).toBeInTheDocument();
});
});
jest.clearAllMocks() or jest.resetAllMocks() in beforeEach to avoid test pollution.
Mocking Axios or Fetch
You can mock HTTP libraries at different levels:
// src/mocks/handlers.js
import { rest } from 'msw';
export const handlers = [
rest.get('/api/todos', (req, res, ctx) => {
return res(
ctx.status(200),
ctx.json([
{ id: 1, title: 'Test todo', completed: false }
])
);
}),
rest.post('/api/todos', async (req, res, ctx) => {
const todo = await req.json();
return res(
ctx.status(201),
ctx.json({ id: Date.now(), ...todo })
);
}),
rest.delete('/api/todos/:id', (req, res, ctx) => {
return res(ctx.status(204));
})
];
// src/mocks/server.js
import { setupServer } from 'msw/node';
import { handlers } from './handlers';
export const server = setupServer(...handlers);
// setupTests.js
import { server } from './mocks/server';
beforeAll(() => server.listen());
afterEach(() => server.resetHandlers());
afterAll(() => server.close());
Testing Custom Hooks
Custom hooks require special testing utilities:
import { useState, useCallback } from 'react';
export 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 };
}
import { renderHook, act } from '@testing-library/react';
import { useCounter } from './useCounter';
describe('useCounter Hook', () => {
test('initializes with default value', () => {
const { result } = renderHook(() => useCounter());
expect(result.current.count).toBe(0);
});
test('initializes with custom value', () => {
const { result } = renderHook(() => useCounter(10));
expect(result.current.count).toBe(10);
});
test('increments count', () => {
const { result } = renderHook(() => useCounter());
act(() => {
result.current.increment();
});
expect(result.current.count).toBe(1);
});
test('decrements count', () => {
const { result } = renderHook(() => useCounter(5));
act(() => {
result.current.decrement();
});
expect(result.current.count).toBe(4);
});
test('resets to initial value', () => {
const { result } = renderHook(() => useCounter(10));
act(() => {
result.current.increment();
result.current.increment();
});
expect(result.current.count).toBe(12);
act(() => {
result.current.reset();
});
expect(result.current.count).toBe(10);
});
test('handles multiple operations', () => {
const { result } = renderHook(() => useCounter());
act(() => {
result.current.increment();
result.current.increment();
result.current.decrement();
});
expect(result.current.count).toBe(1);
});
});
act() to ensure React processes state updates properly. Forgetting this leads to warnings and unreliable tests.
Testing Hooks with Dependencies
Testing hooks that depend on context or props:
import { useContext } from 'react';
import { AuthContext } from './AuthContext';
export function useAuth() {
const context = useContext(AuthContext);
if (!context) {
throw new Error('useAuth must be used within AuthProvider');
}
return context;
}
import { renderHook } from '@testing-library/react';
import { useAuth } from './useAuth';
import { AuthProvider } from './AuthContext';
describe('useAuth Hook', () => {
test('returns auth context value', () => {
const mockUser = { id: 1, name: 'John Doe' };
const wrapper = ({ children }) => (
<AuthProvider value={{ user: mockUser, isAuthenticated: true }}>
{children}
</AuthProvider>
);
const { result } = renderHook(() => useAuth(), { wrapper });
expect(result.current.user).toEqual(mockUser);
expect(result.current.isAuthenticated).toBe(true);
});
test('throws error when used outside provider', () => {
// Suppress console.error for this test
const spy = jest.spyOn(console, 'error').mockImplementation(() => {});
expect(() => {
renderHook(() => useAuth());
}).toThrow('useAuth must be used within AuthProvider');
spy.mockRestore();
});
});
Integration Testing
Integration tests verify multiple components working together:
import { render, screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import App from './App';
import { server } from './mocks/server';
import { rest } from 'msw';
describe('Todo App Integration', () => {
test('complete todo workflow', async () => {
const user = userEvent.setup();
render(<App />);
// Wait for initial todos to load
await waitFor(() => {
expect(screen.getByText('Existing Todo')).toBeInTheDocument();
});
// Add a new todo
const input = screen.getByPlaceholderText(/new todo/i);
await user.type(input, 'Buy milk');
await user.keyboard('{Enter}');
await waitFor(() => {
expect(screen.getByText('Buy milk')).toBeInTheDocument();
});
// Mark todo as complete
const checkbox = screen.getByRole('checkbox', { name: /buy milk/i });
await user.click(checkbox);
await waitFor(() => {
expect(checkbox).toBeChecked();
});
// Delete todo
const deleteButton = screen.getByRole('button', { name: /delete buy milk/i });
await user.click(deleteButton);
await waitFor(() => {
expect(screen.queryByText('Buy milk')).not.toBeInTheDocument();
});
});
test('handles network errors', async () => {
// Override handler to simulate error
server.use(
rest.get('/api/todos', (req, res, ctx) => {
return res(ctx.status(500), ctx.json({ error: 'Server error' }));
})
);
render(<App />);
const errorMessage = await screen.findByText(/failed to load todos/i);
expect(errorMessage).toBeInTheDocument();
});
test('filters todos by status', async () => {
const user = userEvent.setup();
render(<App />);
await waitFor(() => {
expect(screen.getByText('Active Todo')).toBeInTheDocument();
expect(screen.getByText('Completed Todo')).toBeInTheDocument();
});
// Click "Active" filter
const activeFilter = screen.getByRole('button', { name: /active/i });
await user.click(activeFilter);
expect(screen.getByText('Active Todo')).toBeInTheDocument();
expect(screen.queryByText('Completed Todo')).not.toBeInTheDocument();
// Click "Completed" filter
const completedFilter = screen.getByRole('button', { name: /completed/i });
await user.click(completedFilter);
expect(screen.queryByText('Active Todo')).not.toBeInTheDocument();
expect(screen.getByText('Completed Todo')).toBeInTheDocument();
});
});
Snapshot Testing
Snapshot tests capture component output and detect unexpected changes:
import { render } from '@testing-library/react';
import Card from './Card';
describe('Card Component Snapshots', () => {
test('matches snapshot with basic props', () => {
const { container } = render(
<Card title="Test Card" content="This is test content" />
);
expect(container.firstChild).toMatchSnapshot();
});
test('matches snapshot with image', () => {
const { container } = render(
<Card
title="Image Card"
content="Card with image"
image="https://example.com/image.jpg"
/>
);
expect(container.firstChild).toMatchSnapshot();
});
test('matches snapshot in loading state', () => {
const { container } = render(
<Card title="Loading" isLoading={true} />
);
expect(container.firstChild).toMatchSnapshot();
});
});
test('renders button correctly', () => {
const { container } = render(<Button variant="primary">Click Me</Button>);
expect(container.firstChild).toMatchInlineSnapshot(`
<button
class="btn btn-primary"
type="button"
>
Click Me
</button>
`);
});
Code Coverage
Measuring test coverage helps identify untested code:
# Generate coverage report
npm test -- --coverage
# Generate coverage with specific thresholds
npm test -- --coverage --coverageThreshold='{"global":{"branches":80,"functions":80,"lines":80,"statements":80}}'
# Watch mode with coverage
npm test -- --coverage --watchAll=false
{
"jest": {
"collectCoverageFrom": [
"src/**/*.{js,jsx}",
"!src/index.js",
"!src/reportWebVitals.js",
"!src/**/*.test.{js,jsx}",
"!src/mocks/**"
],
"coverageThreshold": {
"global": {
"branches": 80,
"functions": 80,
"lines": 80,
"statements": 80
}
},
"coverageReporters": [
"text",
"lcov",
"html"
]
}
}
Exercise 1: Mock API Service
Create a user profile component that fetches data from an API. Write tests with mocked API calls for:
- Loading state while fetching
- Successful data display
- Error handling with retry button
- Updating profile data
- Verify API called with correct parameters
Exercise 2: Test Custom Hook
Create a useLocalStorage hook that syncs state with localStorage. Test:
- Initial value from localStorage
- Updating value updates localStorage
- Handling invalid JSON in localStorage
- Clearing storage
- Multiple hook instances sync correctly
Exercise 3: Integration Test
Build a shopping cart application with product list, cart, and checkout. Write integration tests for:
- Adding products to cart
- Updating quantities
- Removing items
- Calculating totals correctly
- Completing checkout flow
- Handling out-of-stock scenarios
Test Organization Best Practices
Structure your tests for maintainability:
src/
├── components/
│ ├── Button/
│ │ ├── Button.jsx
│ │ ├── Button.test.jsx
│ │ └── Button.module.css
│ └── Card/
│ ├── Card.jsx
│ ├── Card.test.jsx
│ └── __snapshots__/
│ └── Card.test.jsx.snap
├── hooks/
│ ├── useCounter.js
│ └── useCounter.test.js
├── utils/
│ ├── formatters.js
│ └── formatters.test.js
├── mocks/
│ ├── handlers.js
│ └── server.js
└── __tests__/
└── integration/
└── TodoApp.test.jsx
// testUtils.js
import { render } from '@testing-library/react';
import { AuthProvider } from './contexts/AuthContext';
import { ThemeProvider } from './contexts/ThemeContext';
export function renderWithProviders(
ui,
{
authValue = { user: null, isAuthenticated: false },
theme = 'light',
...renderOptions
} = {}
) {
function Wrapper({ children }) {
return (
<AuthProvider value={authValue}>
<ThemeProvider theme={theme}>
{children}
</ThemeProvider>
</AuthProvider>
);
}
return render(ui, { wrapper: Wrapper, ...renderOptions });
}
// Usage in tests
import { renderWithProviders } from './testUtils';
test('renders with auth context', () => {
renderWithProviders(<MyComponent />, {
authValue: { user: mockUser, isAuthenticated: true }
});
// Test assertions...
});