Part 5.4 — Mocking Fetch, API Clients & Error States
Real users don’t experience APIs as “successful JSON every time.”
They experience slow responses, timeouts, 500 errors, and inconsistent networks.
If your frontend tests only cover happy paths, you’re shipping blind.
In this post, you’ll learn how to mock REST APIs intentionally, simulate failures, and verify that your React UI behaves correctly under real-world conditions—without hitting a real backend.
1. Why Mocking APIs Matters
Frontend tests should be:
fast (milliseconds, not seconds)
deterministic (no flaky network)
focused on UI behavior
independent of backend availability
That means no real HTTP calls in Jest tests.
End-to-end tests (Playwright) are where real APIs belong.
2. Centralize API Calls (Prerequisite)
Never call fetch directly inside components.
Instead, create an API layer:
// src/api/users.ts
export async function fetchUsers() {
const res = await fetch('/api/users');
if (!res.ok) {
throw new Error('Failed to load users');
}
return res.json();
}Components consume this abstraction—not fetch itself.
This single decision makes testing dramatically easier.
3. Mock the API Module, Not Fetch (Preferred)
Mocking fetch globally works, but mocking your API layer is cleaner.
Example test:
jest.mock('../api/users', () => ({
fetchUsers: jest.fn(),
}));Import the mock:
import { fetchUsers } from '../api/users';Now control behavior per test:
(fetchUsers as jest.Mock).mockResolvedValue([
{ id: 1, name: 'Alice' },
]);This avoids coupling tests to low-level HTTP behavior.
4. Testing Success State via API Mock
Given a component:
export function UsersPage() {
const [users, setUsers] = React.useState<User[]>([]);
const [loading, setLoading] = React.useState(true);
React.useEffect(() => {
fetchUsers()
.then(setUsers)
.finally(() => setLoading(false));
}, []);
if (loading) return <p>Loading…</p>;
return (
<ul>
{users.map(u => (
<li key={u.id}>{u.name}</li>
))}
</ul>
);
}Test:
it('renders users from API', async () => {
(fetchUsers as jest.Mock).mockResolvedValue([
{ id: 1, name: 'Alice' },
]);
render(<UsersPage />);
expect(await screen.findByText('Alice')).toBeInTheDocument();
});You’re testing UI behavior, not HTTP mechanics.
5. Testing Error States Explicitly
Update component:
const [error, setError] = React.useState<string | null>(null);
fetchUsers()
.then(setUsers)
.catch(() => setError('Failed to load users'))
.finally(() => setLoading(false));Test failure:
it('shows error on API failure', async () => {
(fetchUsers as jest.Mock).mockRejectedValue(new Error('boom'));
render(<UsersPage />);
expect(
await screen.findByRole('alert'),
).toHaveTextContent('Failed to load users');
});This locks in graceful degradation.
6. Simulating Loading Delays (Without Timeouts)
Never use setTimeout in tests.
Instead, control promise resolution:
let resolvePromise: (v: any) => void;
(fetchUsers as jest.Mock).mockImplementation(
() =>
new Promise(resolve => {
resolvePromise = resolve;
}),
);Test loading state:
render(<UsersPage />);
expect(screen.getByText(/loading/i)).toBeInTheDocument();
resolvePromise!([{ id: 1, name: 'Alice' }]);This keeps tests fast and deterministic.
7. Testing Retry Logic
If your UI retries on failure:
async function load() {
try {
setUsers(await fetchUsers());
} catch {
setError('Retry');
}
}Test multiple outcomes:
(fetchUsers as jest.Mock)
.mockRejectedValueOnce(new Error())
.mockResolvedValueOnce([{ id: 1, name: 'Alice' }]);Then assert retry behavior.
This ensures robustness under unstable networks.
8. Testing Empty States
Empty results matter:
if (!users.length) return <p>No users yet</p>;Test:
(fetchUsers as jest.Mock).mockResolvedValue([]);
render(<UsersPage />);
expect(
await screen.findByText('No users yet'),
).toBeInTheDocument();Empty states are UX features—not edge cases.
9. When to Mock Fetch Directly
Mock fetch directly only when:
you don’t have an API layer yet
you’re testing the API abstraction itself
Example:
(fetch as jest.Mock).mockResolvedValue({
ok: false,
});Prefer API-layer mocks for component tests.
10. Avoid Mocking Too Deeply
Do not:
mock React hooks
mock component internals
mock state setters
Your tests should break if behavior changes—not because mocks are out of sync.
11. Keep Mocks Local to Tests
Reset mocks between tests:
afterEach(() => {
jest.resetAllMocks();
});Never rely on mock state leaking across tests.
12. Common API Mocking Pitfalls
Avoid these mistakes:
❌ snapshot testing API-driven UI
❌ testing HTTP status codes in UI tests
❌ asserting exact request URLs everywhere
❌ mocking fetch and API layer at the same time
❌ using real delays or timeouts
Tests should be fast and boring.
13. Summary
You now know how to:
mock REST API layers cleanly
simulate success, failure, loading, and empty states
test retries and error UI
avoid coupling tests to HTTP details
keep frontend tests deterministic and fast
In Part 5.5, you’ll step up a level and write end-to-end tests with Playwright that validate real user flows against a running backend.
Related
Part 6.6 — Async State, Side Effects & Server State Boundaries
Most React state bugs come from mixing UI state with server state. This post teaches you how to draw a hard line between them—and why that line matters.
Part 6.5 — Introducing Redux Toolkit the Right Way
Redux isn’t the default anymore—but when your app needs it, Redux Toolkit is the cleanest, safest way to introduce global state at scale.
Part 6.4 — Combining Context + useReducer for App-Level State
Context provides access. Reducers provide structure. Together, they form a powerful, lightweight architecture for managing app-level state in React.
Comments