Skip to content

Part 5.4 — Mocking Fetch, API Clients & Error States

Site Console Site Console
4 min read Updated Jan 11, 2026 Frontend Design 0 comments
Mocking REST APIs & Error States in React Tests

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

Leave a comment

Sign in to leave a comment.

Comments