JavaScript and React course Β· Module 13: Testing React
Mocking Modules and API - Flight Simulator
In this lesson4
In a real flight control center, simulators are used to train pilots without risking damage to a real ship. In React tests, mocking serves exactly the same role - it lets you test components without real connections to APIs, databases, or external services.
A test that calls the real server every time is slow, depends on the network and can fail at night just because the API had an outage. A mock replaces the outside world with a predictable stand-in that you control yourself.
jest.mock() - Replacing Modules
jest.mock() lets you replace an entire module with its mock. Every exported function automatically becomes a jest.fn() stand-in:
1// Mock the entire API module
2jest.mock('./api/missionApi');
3
4import { fetchMission, launchMission } from './api/missionApi';
5
6test('displays mission data', async () => {
7 // Set what the mock should return
8 fetchMission.mockResolvedValue({
9 id: 1,
10 name: 'Apollo 13',
11 status: 'Active'
12 });
13
14 render(<MissionPanel />);
15
16 expect(await screen.findByText('Apollo 13')).toBeInTheDocument();
17 expect(fetchMission).toHaveBeenCalledTimes(1);
18});jest.mock sits above the import, but even if it were lower, babel-jest would still hoist it to the very top of the file. Thanks to that, the import already receives the mocked version, and we decide its result with mockResolvedValue. The launchMission function, which this test does not use, is now a stand-in too and returns undefined until you set its result.
Mocking with Implementation
The second argument of jest.mock is a factory that returns your own version of the module. It helps when automatic stand-ins returning undefined are not enough:
1jest.mock('./utils/navigation', () => ({
2 calculateRoute: jest.fn((from, to) => ({
3 distance: 100,
4 duration: '2 hours',
5 fuel: 50
6 })),
7 isValidCoordinate: jest.fn(() => true),
8}));Every function is still a jest.fn, so you can check its calls, but it returns sensible data right away.
Partial Mocking
Sometimes you want to replace just one function and keep the rest of the module real:
1jest.mock('./utils/spacemath', () => ({
2 ...jest.requireActual('./utils/spacemath'),
3 // Override ONLY one function
4 calculateGravity: jest.fn(() => 9.81),
5}));jest.requireActual loads the original module, and the ... spread copies all of its exports. We override only calculateGravity, and everything else works as in production.
Mocking fetch / axios
When a component calls fetch or axios itself, we can replace exactly those functions.
Mocking Global fetch
global.fetch is a global function, so before each test we replace it with a fresh stand-in:
1beforeEach(() => {
2 global.fetch = jest.fn();
3});
4
5afterEach(() => {
6 jest.restoreAllMocks();
7});
8
9test('fetches planets from API', async () => {
10 fetch.mockResolvedValueOnce({
11 ok: true,
12 json: () => Promise.resolve([
13 { id: 1, name: 'Mars' },
14 { id: 2, name: 'Jupiter' },
15 ]),
16 });
17
18 render(<PlanetList />);
19
20 expect(await screen.findByText('Mars')).toBeInTheDocument();
21 expect(screen.getByText('Jupiter')).toBeInTheDocument();
22 expect(fetch).toHaveBeenCalledWith('/api/planets');
23});The stand-in returns an object with an ok field and a json method, because those are the only things the component uses. Watch out for jest.restoreAllMocks(): it restores originals only for mocks created with jest.spyOn. A manually assigned global.fetch is only overwritten in the next beforeEach. To reset call counts, use jest.clearAllMocks().
Cleaning Up Mocks Between Tests
A mock remembers its calls between tests, so the count from one test can break a toHaveBeenCalledTimes assertion in the next one. That is why the cleanup goes in beforeEach:
1beforeEach(() => {
2 jest.clearAllMocks();
3});The three cleanup functions are easy to confuse:
jest.clearAllMocks()resets the call history (mock.calls,mock.results) but keeps the configured responses, such asmockResolvedValuejest.resetAllMocks()does the same and also removes the responses, so mocks returnundefinedagainjest.restoreAllMocks()restores the real functions replaced withjest.spyOn
The clearMocks, resetMocks and restoreMocks options in the Jest config give the same effect without writing beforeEach.
Mocking axios
We treat the axios library like any other module. For typing text we use userEvent.setup():
1jest.mock('axios');
2import axios from 'axios';
3import userEvent from '@testing-library/user-event';
4
5test('posts new mission', async () => {
6 const user = userEvent.setup();
7 axios.post.mockResolvedValue({
8 data: { id: 42, name: 'New Mission', status: 'Created' }
9 });
10
11 render(<CreateMissionForm />);
12
13 await user.type(screen.getByLabelText('Mission Name'), 'Artemis');
14 await user.click(screen.getByRole('button', { name: 'Create' }));
15
16 expect(axios.post).toHaveBeenCalledWith('/api/missions', {
17 name: 'Artemis'
18 });
19 expect(await screen.findByText('Mission created!')).toBeInTheDocument();
20});We check two things: whether axios.post received the right URL and data, and whether the user saw the success message.
MSW (Mock Service Worker) - Advanced API Mocking
Mocking fetch or axios directly works, but has a drawback - you're testing implementation (whether you used fetch or axios), not behavior (whether data was fetched). MSW solves this problem by intercepting requests at the network level. It's like a simulator that mimics real satellite communication - your component "thinks" it's talking to a real server, but in reality MSW generates the responses. This makes the test more realistic and resistant to implementation changes. The code below uses the MSW 2 API, http and HttpResponse. The older rest with res(ctx.json()) is MSW 1 syntax, removed in version 2:
1import { http, HttpResponse } from 'msw';
2import { setupServer } from 'msw/node';
3
4// Define handlers
5const handlers = [
6 http.get('/api/missions', () => {
7 return HttpResponse.json([
8 { id: 1, name: 'Apollo 11', status: 'Completed' },
9 { id: 2, name: 'Artemis I', status: 'Active' },
10 ]);
11 }),
12
13 http.post('/api/missions', async ({ request }) => {
14 const body = await request.json();
15 return HttpResponse.json(
16 { id: 3, name: body.name, status: 'Created' },
17 { status: 201 }
18 );
19 }),
20];
21
22// Create server
23const server = setupServer(...handlers);
24
25// Setup and teardown
26beforeAll(() => server.listen());
27afterEach(() => server.resetHandlers());
28afterAll(() => server.close());
29
30test('lists missions from API', async () => {
31 render(<MissionList />);
32
33 expect(await screen.findByText('Apollo 11')).toBeInTheDocument();
34 expect(screen.getByText('Artemis I')).toBeInTheDocument();
35});setupServer from msw/node starts interception in the Node environment where Jest runs. The POST handler reads the request body with await request.json(), just like a real server would.
In Jest, MSW needs two configuration fixes. First, MSW needs the global Request, Response and fetch, which the jsdom environment does not have, so with plain testEnvironment: 'jsdom' the test fails with Request is not defined. The MSW documentation recommends the jest-fixed-jsdom package and testEnvironment: 'jest-fixed-jsdom'. Second, some MSW dependencies are published only as ES modules, so on Node older than 24.9 Jest reports Must use import to load ES Module. In that case add the packages named in the message to the exceptions in transformIgnorePatterns and set transform so that babel-jest also handles .mjs files.
Overriding Handlers in a Single Test
server.use adds a handler for a single test only, and resetHandlers in afterEach restores the initial setup:
1test('handles server error', async () => {
2 // Only for this test -- return an error
3 server.use(
4 http.get('/api/missions', () => {
5 return HttpResponse.json({ error: 'Server error' }, { status: 500 });
6 })
7 );
8
9 render(<MissionList />);
10
11 expect(await screen.findByText('Failed to load missions')).toBeInTheDocument();
12});This is how you test the error path without changing the shared handlers. The component receives status 500 and shows a failure message.
Mocking Timers
Many components use setTimeout or setInterval - for example, auto-refreshing data every 30 seconds, mission launch countdown, or animations. In tests, we don't want to wait a real 30 seconds! Jest provides fake timers that let you "fast-forward time" like in a flight simulator. Call jest.useFakeTimers(), then jest.advanceTimersByTime(ms) to skip by a specified number of milliseconds. Take a panel that fetches data on mount and then every interval milliseconds:
1function AutoRefreshPanel({ fetchData, interval }) {
2 const [data, setData] = useState(null);
3
4 useEffect(() => {
5 const load = () => fetchData().then(setData);
6 load();
7 const id = setInterval(load, interval);
8 return () => clearInterval(id);
9 }, [fetchData, interval]);
10
11 return <p>{data ? 'Data refreshed' : 'Loading...'}</p>;
12}The test gives the component a fetchData mock that returns data at once and moves the clock forward by full intervals:
1test('auto-refreshes data every 30 seconds', async () => {
2 jest.useFakeTimers();
3 const mockFetch = jest.fn().mockResolvedValue({ data: [] });
4
5 render(<AutoRefreshPanel fetchData={mockFetch} interval={30000} />);
6
7 // First call on mount
8 expect(mockFetch).toHaveBeenCalledTimes(1);
9
10 // After 30 seconds: second call
11 await act(async () => {
12 jest.advanceTimersByTime(30000);
13 });
14 expect(mockFetch).toHaveBeenCalledTimes(2);
15
16 // After another 30 seconds
17 await act(async () => {
18 jest.advanceTimersByTime(30000);
19 });
20 expect(mockFetch).toHaveBeenCalledTimes(3);
21
22 jest.useRealTimers();
23});Every 30-second jump fires one refresh cycle, so the call count grows by one. The mock returns a Promise, so the new state appears only once that Promise resolves, a moment after the clock moves. That is why we use the async version await act(async () => ...): it also waits for such promises before it finishes. With a plain act(() => ...) the counts would still match, but React would print three "not wrapped in act(...)" warnings in the console.
My advice: for components that talk to an API, choose MSW, and keep jest.mock for modules without network access. In the next lesson you will test your own hooks. Remember: a mock is a flight simulator where you rehearse every scenario, failure included, before heading into real space.
Code for this lesson: App.jsx
1import React, { useState, useEffect, useCallback } from 'react';
2
3// ===== API module (in a project it would be a separate missionApi.js file) =====
4// The preview has no server, so the module fakes the network: Promise + setTimeout.
5// In a test you replace it with jest.mock('./missionApi') or MSW.
6const STATUS_LABELS = { completed: 'completed', active: 'active', created: 'created' };
7
8let serverMissions = [
9 { id: 1, name: 'Apollo 11', status: 'completed' },
10 { id: 2, name: 'Artemis I', status: 'active' },
11];
12let serverDown = false;
13
14const missionApi = {
15 fetchMissions() {
16 return new Promise((resolve, reject) => {
17 setTimeout(() => {
18 if (serverDown) reject(new Error('HTTP 500'));
19 else resolve([...serverMissions]);
20 }, 700);
21 });
22 },
23 createMission(data) {
24 return new Promise(resolve => {
25 setTimeout(() => {
26 const created = { id: Date.now(), name: data.name, status: 'created' };
27 serverMissions = [...serverMissions, created];
28 resolve(created);
29 }, 400);
30 });
31 },
32 setServerDown(value) {
33 serverDown = value;
34 },
35};
36
37// The component that gets the mocked API in tests
38function MissionCenter() {
39 const [missions, setMissions] = useState([]);
40 const [loading, setLoading] = useState(true);
41 const [error, setError] = useState(null);
42 const [newName, setNewName] = useState('');
43
44 const loadMissions = useCallback(async () => {
45 setLoading(true);
46 try {
47 // In tests: missionApi.fetchMissions.mockResolvedValue([...])
48 const data = await missionApi.fetchMissions();
49 setMissions(data);
50 setError(null);
51 } catch (err) {
52 setError('Failed to load missions');
53 } finally {
54 setLoading(false);
55 }
56 }, []);
57
58 useEffect(() => {
59 loadMissions();
60 }, [loadMissions]);
61
62 const handleCreate = async (e) => {
63 e.preventDefault();
64 if (!newName.trim()) return;
65 const created = await missionApi.createMission({ name: newName.trim() });
66 setMissions(prev => [...prev, created]);
67 setNewName('');
68 };
69
70 if (loading) return <div className="status" role="status">Loading missions...</div>;
71
72 if (error) {
73 return (
74 <div className="status error">
75 <p role="alert">{error}</p>
76 <button className="refresh" onClick={loadMissions}>Try again</button>
77 </div>
78 );
79 }
80
81 return (
82 <div className="mission-center">
83 <h2>Mission Center</h2>
84
85 <form className="create-form" onSubmit={handleCreate}>
86 <input
87 type="text"
88 placeholder="New mission name..."
89 value={newName}
90 onChange={e => setNewName(e.target.value)}
91 aria-label="Mission name"
92 />
93 <button type="submit">Create</button>
94 </form>
95
96 <ul className="mission-list">
97 {missions.map(m => (
98 <li key={m.id}>{m.name} - {STATUS_LABELS[m.status]}</li>
99 ))}
100 </ul>
101
102 <button className="refresh" onClick={loadMissions}>
103 Refresh
104 </button>
105 </div>
106 );
107}
108
109// Auto-refresh: in tests you fast-forward time with fake timers
110function AutoRefreshStatus({ interval = 5000 }) {
111 const [systemStatus, setSystemStatus] = useState({
112 engine: 'nominal',
113 shields: 100,
114 lastUpdate: new Date().toLocaleTimeString('en-US'),
115 });
116 const [refreshCount, setRefreshCount] = useState(0);
117
118 useEffect(() => {
119 const timer = setInterval(() => {
120 setRefreshCount(c => c + 1);
121 setSystemStatus(prev => ({
122 ...prev,
123 shields: Math.max(prev.shields - 1, 0),
124 lastUpdate: new Date().toLocaleTimeString('en-US'),
125 }));
126 }, interval);
127
128 return () => clearInterval(timer);
129 }, [interval]);
130
131 return (
132 <div className="auto-refresh">
133 <h2>System status (refresh every {interval / 1000} s)</h2>
134 <div className="status-grid">
135 <div>Engine: {systemStatus.engine}</div>
136 <div>Shields: {systemStatus.shields}%</div>
137 <div>Last reading: {systemStatus.lastUpdate}</div>
138 <div>Refresh count: {refreshCount}</div>
139 </div>
140 </div>
141 );
142}
143
144export default function App() {
145 const [down, setDown] = useState(false);
146 const [run, setRun] = useState(0);
147
148 const toggleServer = (e) => {
149 missionApi.setServerDown(e.target.checked);
150 setDown(e.target.checked);
151 setRun(r => r + 1);
152 };
153
154 return (
155 <div className="app">
156 <h1>Mocking Modules and API</h1>
157 <p className="info">
158 The preview does not run Jest. Here the missionApi module fakes the server
159 (Promise + setTimeout), and in the lesson's tests you replace it with a mock.
160 </p>
161 <label className="server-toggle">
162 <input type="checkbox" checked={down} onChange={toggleServer} />
163 Simulate a server failure (HTTP 500)
164 </label>
165 <MissionCenter key={run} />
166 <AutoRefreshStatus interval={3000} />
167 </div>
168 );
169}Spotted a mistake in this lesson?
Check yourself
Answer the questions from this lesson. Pick an answer to see right away whether it is correct.
1. What does jest.mock('./api/missionApi') do in a test?
2. What is the main advantage of MSW (Mock Service Worker) compared with mocking fetch?
Hands-on tasks in the game
- Vertical ordering
Arrange the stages of a mock's lifecycle in tests in the correct order:
- Code editor
Finish the missionReducer reducer and the useMissionTracker hook of the mission log. ___BLANK1___: the START action changes the status of a planned mission to 'active' by returning a new object with the other mission fields (do not change the object that is in the state). ___BLANK2___: the REMOVE action returns a new array without the mission whose id is in the action (action.id). ___BLANK3___: the hook passes the missionReducer reducer to useReducer. The test checks the reducer as a pure function on a frozen state (Object.freeze), and runs the hook in a small test component: it adds two missions, starts, completes and removes them.
- Click in order
Arrange the elements of the jest.fn().mockResolvedValue() syntax in the correct order:
- Horizontal ordering
Arrange the elements of the beforeEach syntax with mock reset in the correct order: