JavaScript and React course Β· Module 13: Testing React

Mocking Modules and API - Flight Simulator

8 min read
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 as mockResolvedValue
  • jest.resetAllMocks() does the same and also removes the responses, so mocks return undefined again
  • jest.restoreAllMocks() restores the real functions replaced with jest.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. 1. What does jest.mock('./api/missionApi') do in a test?

  2. 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:

Useful articles