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
: Usingjest.spyOn(window, 'fetch')
orglobal.fetch = jest.fn()
. - ✅ Simulating Successful API Responses:
mockResolvedValueOnce
andmockImplementation
. - ✅ 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:
- Slow Tests: Network requests add significant latency, making your test suite slow to run. Fast feedback is crucial for effective testing.
- 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.
- Side Effects: Tests might unintentionally create, modify, or delete data on a real backend, which is undesirable, especially in shared development or CI environments.
- Dependency on External Services: Your frontend tests become dependent on the availability and state of backend services.
- 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:
-
jest.fn()
for global properties: If the API function is a property of a global object (likewindow.fetch
), you can directly assign ajest.fn()
to it.global.fetch = jest.fn(); // Replaces the global fetch with a Jest mock function
-
jest.spyOn(object, methodName)
: This is often preferred for existing functions on objects (including global objects likewindow
). 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 usingmockRestore()
. -
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 mockResponse
object.
fetchSpy.mockResolvedValueOnce({
ok: true,
status: 200,
json: async () => ({ id: 1, title: 'Mocked Post' }),
}); - Makes the mock function return a Promise that resolves with the given
-
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'));
- Makes the mock function return a Promise that rejects with the given
-
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' }),
});
}
}); - Provides a custom implementation for the mock function. The provided function
✨ 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
andafterEach
:- We set up
global.fetch = jest.fn()
inbeforeEach
to ensure each test gets a fresh mock. fetchMock.mockRestore()
inafterEach
is good practice iffetchMock
was created withjest.spyOn
. Ifglobal.fetch
was simply reassigned, ensure it's cleaned up or that tests don't interfere. For this example, reassigningglobal.fetch = jest.fn()
in eachbeforeEach
is sufficient for isolation.
- We set up
fetchMock.mockResolvedValueOnce(...)
: Used to simulate a successful API response. The object it resolves to must mimic the structure of aResponse
object, including anok: true
property and ajson
method that returns another Promise resolving to your mock data.fetchMock.mockRejectedValueOnce(...)
: Simulates a network error (e.g.,fetch
itself throws).await screen.findByText(...)
orawait waitFor(...)
: When testing components that fetch data, the data isn't there immediately.findBy*
queries (or wrapping assertions inwaitFor
from RTL) are essential to wait for the component to re-render after the asynchronous operation completes.- Asserting
fetch
Calls: We check thatfetchMock
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()
orjest.spyOn(window, 'fetch')
to mock the globalfetch
API. - Simulate successful responses with
mockResolvedValueOnce
(returning a mockResponse
object). - Simulate error responses with
mockRejectedValueOnce
(for network errors) ormockResolvedValueOnce
withok: false
(for HTTP errors). - Use
async/await
withfindBy*
queries orwaitFor
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 tojest.fn()
but also spies on the calls toobject[methodName]
. It allows restoring the original implementation.mockResolvedValueOnce(value)
(Jest Mock): Configures a mock function to return a Promise that resolves withvalue
for its next call.mockRejectedValueOnce(error)
(Jest Mock): Configures a mock function to return a Promise that rejects witherror
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 usewaitFor
internally.
Further Reading
- Jest: Mocking Modules
- Jest:
jest.spyOn()
- React Testing Library: Async Methods (
findBy*
,waitFor
) - Testing Components That Fetch Data with React Testing Library - Robin Wieruch