Skip to main content

Mocking API Calls in Tests (Part 1) #151

📖 Introduction

After learning how to test component rendering and Testing User Interactions, a critical next step is handling components that interact with external APIs. Making real network requests in unit or integration tests is slow, unreliable, and can lead to unintended side effects. This article, Part 1 of a two-part series, introduces the concept of mocking API calls using Jest's built-in mocking capabilities, focusing on mocking the global fetch API.


📚 Prerequisites

Before we begin, ensure you have:

  • A working Jest and React Testing Library setup.
  • Understanding of asynchronous JavaScript (Promises, async/await).
  • Familiarity with the fetch API for making network requests.
  • Basic knowledge of Jest mock functions (jest.fn()).

🎯 Article Outline: What You'll Master

In this article, you will learn:

  • Why Mock API Calls? Benefits of isolating components from real network dependencies.
  • Introduction to Jest's Mocking Scope: jest.mock, jest.spyOn.
  • Mocking Global fetch: Using jest.spyOn(window, 'fetch') or global.fetch = jest.fn().
  • Simulating Successful API Responses: mockResolvedValueOnce and mockImplementation.
  • Testing a Component that Fetches Data on Mount.
  • Resetting Mocks Between Tests.

🧠 Section 1: Why Mock API Calls in Tests?

When testing components that fetch or send data, relying on actual network requests to a backend server has several drawbacks:

  1. Slow Tests: Network requests add significant latency, making your test suite slow to run. Fast feedback is crucial for effective testing.
  2. Unreliable Tests (Flakiness): Tests can fail due to network issues, server downtime, or changes in backend data, even if the component itself is working correctly. This makes tests unreliable.
  3. Side Effects: Tests might unintentionally create, modify, or delete data on a real backend, which is undesirable, especially in shared development or CI environments.
  4. Dependency on External Services: Your frontend tests become dependent on the availability and state of backend services.
  5. Difficult to Test Edge Cases: It's hard to reliably simulate specific API error responses (like 404s, 500s) or specific data scenarios with a real backend.

Mocking solves these problems by replacing the actual API call mechanism (e.g., fetch, Axios instance) with a controlled "fake" version during tests. This fake version allows you to:

  • Define what data the "API call" should return.
  • Simulate success and error responses instantly.
  • Verify that the component makes API calls correctly (e.g., correct URL, method, body).
  • Keep tests fast, reliable, and isolated.

💻 Section 2: Introduction to Jest's Mocking Scope

Jest provides several ways to mock functions and modules. For API calls, particularly global ones like fetch or methods on imported libraries like Axios, two common approaches are:

  1. jest.fn() for global properties: If the API function is a property of a global object (like window.fetch), you can directly assign a jest.fn() to it.

    global.fetch = jest.fn(); // Replaces the global fetch with a Jest mock function
  2. jest.spyOn(object, methodName): This is often preferred for existing functions on objects (including global objects like window). It "spies" on the original function, allowing you to track its calls and also override its implementation for tests.

    jest.spyOn(window, 'fetch'); // Spies on window.fetch
    // or for an imported module:
    // import axios from 'axios';
    // jest.spyOn(axios, 'get');

    jest.spyOn is powerful because it allows you to restore the original implementation later if needed using mockRestore().

  3. jest.mock('./myModule') (Module Mocking): Used to automatically mock all exports of a module. This is very useful for mocking imported libraries like Axios or custom API service modules. We'll cover module mocking in more detail in Part 2.

For this article, we'll primarily focus on mocking the global fetch using jest.spyOn or direct assignment with jest.fn().


🛠️ Section 3: Mocking Global fetch

The fetch API is part of the browser's global window object. In a Jest/JSDOM environment, it's typically available on global.fetch or window.fetch.

3.1 - Basic fetch Mock with jest.fn()

You can replace global.fetch with a Jest mock function. This mock will then need an implementation to return a Promise that resolves to a mock Response object.

// In your test file or setupTests.js for global setup
global.fetch = jest.fn(() =>
Promise.resolve({
ok: true, // Indicates a successful HTTP response (status 200-299)
json: () => Promise.resolve({ data: 'mocked data' }), // Mock the .json() method
text: () => Promise.resolve('mocked text data'), // Mock the .text() method
status: 200,
statusText: 'OK',
// Add other Response properties if your component uses them
})
);

This replaces fetch globally for all tests in the file (or project if in setupTests.js). You'd then assert that fetch was called and your component handles the mocked response.

3.2 - Using jest.spyOn(window, 'fetch')

This is often a cleaner way as it allows more flexible mock implementations per test and easier restoration.

// In your test file
let fetchSpy;

beforeEach(() => {
fetchSpy = jest.spyOn(window, 'fetch'); // Spy on window.fetch
});

afterEach(() => {
fetchSpy.mockRestore(); // Restore original fetch after each test
});

test('should call fetch', async () => {
// Provide a mock implementation for this specific test
fetchSpy.mockResolvedValueOnce({
ok: true,
json: async () => ({ message: 'Success!' }),
status: 200
});
// ... your component rendering and assertions ...
});

🔬 Section 4: Simulating API Responses

Once fetch (or your API client method) is mocked, you need to tell it what to return for specific tests. Jest mock functions have several methods for this:

  • mockResolvedValue(value) / mockResolvedValueOnce(value):

    • Makes the mock function return a Promise that resolves with the given value.
    • mockResolvedValueOnce applies only to the next call.
    • This is useful for simulating successful API responses. The value should be a mock Response object.
    fetchSpy.mockResolvedValueOnce({
    ok: true,
    status: 200,
    json: async () => ({ id: 1, title: 'Mocked Post' }),
    });
  • mockRejectedValue(error) / mockRejectedValueOnce(error):

    • Makes the mock function return a Promise that rejects with the given error object.
    • Useful for simulating network errors or API errors that result in a rejected Promise.
    fetchSpy.mockRejectedValueOnce(new Error('Network failure'));
  • mockImplementation(fn) / mockImplementationOnce(fn):

    • Provides a custom implementation for the mock function. The provided function fn will be executed when the mock is called.
    • This is the most flexible option, allowing you to dynamically return different responses based on arguments or test conditions.
    fetchSpy.mockImplementationOnce((url) => {
    if (url.includes('/users/1')) {
    return Promise.resolve({
    ok: true,
    status: 200,
    json: async () => ({ id: 1, name: 'Alice' }),
    });
    } else {
    return Promise.resolve({
    ok: false,
    status: 404,
    json: async () => ({ message: 'Not Found' }),
    });
    }
    });

✨ Section 5: Testing a Component that Fetches Data on Mount

Let's create a component that fetches and displays user data.

// src/components/UserProfileFetcher.jsx
import React, { useState, useEffect } from 'react';

const UserProfileFetcher = ({ userId }) => {
const [user, setUser] = useState(null);
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState(null);

useEffect(() => {
let isMounted = true;
const fetchUser = async () => {
setIsLoading(true);
setError(null);
setUser(null);
try {
const response = await fetch(`https://jsonplaceholder.typicode.com/users/${userId}`);
if (!response.ok) {
throw new Error(`Failed to fetch user: ${response.status}`);
}
const data = await response.json();
if (isMounted) {
setUser(data);
}
} catch (err) {
if (isMounted) {
setError(err.message);
}
} finally {
if (isMounted) {
setIsLoading(false);
}
}
};

if (userId) {
fetchUser();
}

return () => { isMounted = false; };
}, [userId]);

if (isLoading) return <p>Loading user profile...</p>;
if (error) return <p role="alert" style={{ color: 'red' }}>Error: {error}</p>;
if (!user) return <p>No user data.</p>;

return (
<div>
<h2>User Profile</h2>
<p><strong>ID:</strong> {user.id}</p>
<p><strong>Name:</strong> {user.name}</p>
<p><strong>Email:</strong> {user.email}</p>
</div>
);
};

export default UserProfileFetcher;

Test File for UserProfileFetcher.jsx:

// src/components/UserProfileFetcher.test.js
import React from 'react';
import { render, screen, waitFor } from '@testing-library/react';
// No userEvent needed for this particular test, but good to have for others
// import userEvent from '@testing-library/user-event';
import UserProfileFetcher from './UserProfileFetcher';

// We'll mock global.fetch for these tests
// It's common to put this in beforeEach/afterEach or jest.spyOn(window, 'fetch')
let fetchMock;

describe('UserProfileFetcher Component', () => {
beforeEach(() => {
// Create a new mock for each test to ensure isolation
fetchMock = jest.fn();
global.fetch = fetchMock;
});

afterEach(() => {
// Clean up the mock after each test
fetchMock.mockRestore(); // If using jest.spyOn, otherwise not strictly needed for jest.fn() re-assignment
// Or if you assigned to global.fetch, you might want to restore original if it existed.
// For simplicity here, we just ensure fetchMock doesn't leak.
});

test('displays loading state initially and then user data on successful fetch', async () => {
const mockUserData = { id: 1, name: 'Alice Wonderland', email: '[email protected]' };
// Configure the mock for this test
fetchMock.mockResolvedValueOnce({
ok: true,
status: 200,
json: async () => mockUserData,
});

render(<UserProfileFetcher userId="1" />);

// 1. Check for loading state
expect(screen.getByText('Loading user profile...')).toBeInTheDocument();

// 2. Wait for API call to resolve and component to re-render
// findBy* queries wait for elements to appear
expect(await screen.findByText('User Profile')).toBeInTheDocument(); // Wait for heading

// 3. Assert user data is displayed
expect(screen.getByText(`ID: ${mockUserData.id}`)).toBeInTheDocument();
expect(screen.getByText(`Name: ${mockUserData.name}`)).toBeInTheDocument();
expect(screen.getByText(`Email: ${mockUserData.email}`)).toBeInTheDocument();

// 4. Assert fetch was called correctly
expect(fetchMock).toHaveBeenCalledTimes(1);
expect(fetchMock).toHaveBeenCalledWith('https://jsonplaceholder.typicode.com/users/1');

// 5. Assert loading state is gone and no error is shown
expect(screen.queryByText('Loading user profile...')).not.toBeInTheDocument();
expect(screen.queryByRole('alert')).not.toBeInTheDocument(); // No error message
});

test('displays error message on failed fetch (network error)', async () => {
// Configure fetch to simulate a network error
fetchMock.mockRejectedValueOnce(new Error('Simulated Network Error'));

render(<UserProfileFetcher userId="2" />);

expect(screen.getByText('Loading user profile...')).toBeInTheDocument();

// Wait for the error message to appear
const errorMessage = await screen.findByRole('alert');
expect(errorMessage).toBeInTheDocument();
expect(errorMessage).toHaveTextContent('Error: Simulated Network Error');

expect(fetchMock).toHaveBeenCalledWith('https://jsonplaceholder.typicode.com/users/2');
expect(screen.queryByText('Loading user profile...')).not.toBeInTheDocument();
expect(screen.queryByText('User Profile')).not.toBeInTheDocument(); // Data should not render
});

test('displays error message on failed fetch (API error response)', async () => {
// Configure fetch to simulate a server error response (e.g., 404)
fetchMock.mockResolvedValueOnce({
ok: false, // This is key for non-2xx responses
status: 404,
statusText: 'Not Found',
// json: async () => ({ message: 'User not found' }) // Optional: if your API returns JSON error body
});

render(<UserProfileFetcher userId="invalid" />);

expect(screen.getByText('Loading user profile...')).toBeInTheDocument();

const errorMessage = await screen.findByRole('alert');
expect(errorMessage).toBeInTheDocument();
expect(errorMessage).toHaveTextContent('Error: Failed to fetch user: 404');

expect(fetchMock).toHaveBeenCalledWith('https://jsonplaceholder.typicode.com/users/invalid');
});

test('does not fetch if userId is not provided', () => {
render(<UserProfileFetcher userId={null} />);
expect(screen.getByText('No user data.')).toBeInTheDocument();
expect(fetchMock).not.toHaveBeenCalled();
});
});

Explanation:

  • beforeEach and afterEach:
    • We set up global.fetch = jest.fn() in beforeEach to ensure each test gets a fresh mock.
    • fetchMock.mockRestore() in afterEach is good practice if fetchMock was created with jest.spyOn. If global.fetch was simply reassigned, ensure it's cleaned up or that tests don't interfere. For this example, reassigning global.fetch = jest.fn() in each beforeEach is sufficient for isolation.
  • fetchMock.mockResolvedValueOnce(...): Used to simulate a successful API response. The object it resolves to must mimic the structure of a Response object, including an ok: true property and a json method that returns another Promise resolving to your mock data.
  • fetchMock.mockRejectedValueOnce(...): Simulates a network error (e.g., fetch itself throws).
  • await screen.findByText(...) or await waitFor(...): When testing components that fetch data, the data isn't there immediately. findBy* queries (or wrapping assertions in waitFor from RTL) are essential to wait for the component to re-render after the asynchronous operation completes.
  • Asserting fetch Calls: We check that fetchMock was called the expected number of times and with the correct URL.

💡 Conclusion & Key Takeaways (Part 1)

Mocking API calls is a fundamental technique for writing effective unit and integration tests for React components that interact with external services. By replacing fetch (or other API clients) with Jest mocks, we can create fast, reliable, and isolated tests, and easily simulate various success and error scenarios.

Key Takeaways:

  • Mock API calls to avoid slow, unreliable tests and unwanted side effects.
  • Use global.fetch = jest.fn() or jest.spyOn(window, 'fetch') to mock the global fetch API.
  • Simulate successful responses with mockResolvedValueOnce (returning a mock Response object).
  • Simulate error responses with mockRejectedValueOnce (for network errors) or mockResolvedValueOnce with ok: false (for HTTP errors).
  • Use async/await with findBy* queries or waitFor to test components that update after asynchronous operations.
  • Always reset or restore mocks between tests for isolation (e.g., in beforeEach/afterEach).

➡️ Next Steps

We've covered mocking the global fetch API. However, many applications use dedicated API client libraries (like Axios) or encapsulate API calls within service modules. In "Mocking API Calls in Tests (Part 2)", we will explore:

  • Mocking JavaScript modules (e.g., an API service module or Axios) using jest.mock().
  • More advanced scenarios and patterns for API mocking.

Stay tuned to become a master of mocking!


glossary

  • Mocking (in tests): Replacing real dependencies (like API calls, modules, functions) with controlled fakes (mocks) to isolate the code under test and make tests predictable.
  • jest.spyOn(object, methodName): A Jest function that creates a mock function similar to jest.fn() but also spies on the calls to object[methodName]. It allows restoring the original implementation.
  • mockResolvedValueOnce(value) (Jest Mock): Configures a mock function to return a Promise that resolves with value for its next call.
  • mockRejectedValueOnce(error) (Jest Mock): Configures a mock function to return a Promise that rejects with error for its next call.
  • mockImplementation(fn) (Jest Mock): Provides a custom function implementation for a mock.
  • waitFor (RTL): A utility from React Testing Library to wait for an expectation to pass, useful for assertions after asynchronous updates. findBy* queries use waitFor internally.

Further Reading