Skip to content

Commit bfae4b9

Browse files
JakobJingleheimerAugustinMauroyAviv Kelleraduh95
authored
Add mocking guide (#7092)
* Add mocking guide * fixup: add explanation of mocking (and stubs) * fixup: add links to node docs & improve formatting * fixup: apply suggestions from code review Co-authored-by: Augustin Mauroy <[email protected]> Co-authored-by: Aviv Keller <[email protected]> Signed-off-by: Jacob Smith <[email protected]> * fixup: wordsmith and add missing assertion to test case * Update mocking.md Co-authored-by: Aviv Keller <[email protected]> Signed-off-by: Jacob Smith <[email protected]> * fixup: apply suggestions from code review (installing undici & wordsmith `bar`) Co-authored-by: Antoine du Hamel <[email protected]> Signed-off-by: Jacob Smith <[email protected]> * fixup: wordsmith and sequence --------- Signed-off-by: Jacob Smith <[email protected]> Co-authored-by: Augustin Mauroy <[email protected]> Co-authored-by: Aviv Keller <[email protected]> Co-authored-by: Antoine du Hamel <[email protected]>
1 parent f5d9bde commit bfae4b9

File tree

3 files changed

+276
-1
lines changed

3 files changed

+276
-1
lines changed

apps/site/navigation.json

+4
Original file line numberDiff line numberDiff line change
@@ -357,6 +357,10 @@
357357
"usingTestRunner": {
358358
"link": "/learn/test-runner/using-test-runner",
359359
"label": "components.navigation.learn.testRunner.links.usingTestRunner"
360+
},
361+
"mocking": {
362+
"link": "/learn/test-runner/mocking",
363+
"label": "components.navigation.learn.testRunner.links.mocking"
360364
}
361365
}
362366
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,270 @@
1+
---
2+
title: Mocking in tests
3+
layout: learn
4+
authors: JakobJingleheimer
5+
---
6+
7+
# Mocking in tests
8+
9+
Mocking is a means of creating a facsimile, a puppet. This is generally done in a `when 'a', do 'b'` manner of puppeteering. The idea is to limit the number of moving pieces and control things that "don't matter". "mocks" and "stubs" are technically different kinds of "test doubles". For the curious mind, a stub is a replacement that does nothing (a no-op) but track its invocation. A mock is a stub that also has a fake implementation (the `when 'a', do 'b'`). Within this doc, the difference is unimportant, and stubs are referred to as mocks.
10+
11+
Tests should be deterministic: runnable in any order, any number of times, and always produce the same result. Proper setup and mocking make this possible.
12+
13+
Node.js provides many ways to mock various pieces of code.
14+
15+
This articles deals with the following types of tests:
16+
17+
| type | description | example | mock candidates |
18+
| :--------------- | :---------------------------------------- | :--------------------------------------------------------------------------------------------- | :--------------------------------------- |
19+
| unit | the smallest bit of code you can isolate | `const sum = (a, b) => a + b` | own code, external code, external system |
20+
| component | a unit + dependencies | `const arithmetic = (op = sum, a, b) => ops[op](a, b)` | external code, external system |
21+
| integration | components fitting together | - | external code, external system |
22+
| end-to-end (e2e) | app + external data stores, delivery, etc | A fake user (ex a Playwright agent) literally using an app connected to real external systems. | none (do not mock) |
23+
24+
There are different schools of thought about when to mock and when not to mock, the broad strokes of which are outlined below.
25+
26+
## When and not to mock
27+
28+
There are 3 main mock candidates:
29+
30+
- Own code
31+
- External code
32+
- External system
33+
34+
### Own code
35+
36+
This is what your project controls.
37+
38+
```mjs displayName="your-project/main.mjs"
39+
import foo from './foo.mjs';
40+
41+
export function main() {
42+
const f = foo();
43+
}
44+
```
45+
46+
Here, `foo` is an "own code" dependency of `main`.
47+
48+
#### Why
49+
50+
For a true unit test of `main`, `foo` should be mocked: you're testing that `main` works, not that `main` + `foo` work (that's a different test).
51+
52+
#### Why not
53+
54+
Mocking `foo` can be more trouble than worth, especially when `foo` is simple, well-tested, and rarely updated.
55+
56+
Not mocking `foo` can be better because it's more authentic and increases coverage of `foo` (because `main`'s tests will also verify `foo`). This can, however, create noise: when `foo` breaks, a bunch of other tests will also break, so tracking down the problem is more tedious: if only the 1 test for the item ultimately responsible for the issue is failing, that's very easy to spot; whereas 100 tests failing creates a needle-in-a-haystack to find the real problem.
57+
58+
### External code
59+
60+
This is what your project does not control.
61+
62+
```mjs displayName="your-project/main.mjs"
63+
import bar from 'bar';
64+
65+
export function main() {
66+
const f = bar();
67+
}
68+
```
69+
70+
Here, `bar` is an external package, e.g. an npm dependency.
71+
72+
Uncontroversially, for unit tests, this should always be mocked. For component and integration tests, whether to mock depends on what this is.
73+
74+
#### Why
75+
76+
Verifying that code that your project does not maintain works is not the goal of a unit test (and that code should have its own tests).
77+
78+
#### Why not
79+
80+
Sometimes, it's just not realistic to mock. For example, you would almost never mock a large framework such as react or angular (the medicine would be worse than the ailment).
81+
82+
### External system
83+
84+
These are things like databases, environments (Chromium or Firefox for a web app, an operating system for a node app, etc), file systems, memory store, etc.
85+
86+
Ideally, mocking these would not be necessary. Aside from somehow creating isolated copies for each case (usually very impractical due to cost, additional execution time, etc), the next best option is to mock. Without mocking, tests sabotage each other:
87+
88+
```mjs displayName="storage.mjs"
89+
import { db } from 'db';
90+
91+
export function read(key, all = false) {
92+
validate(key, val);
93+
94+
if (all) return db.getAll(key);
95+
96+
return db.getOne(key);
97+
}
98+
99+
export function save(key, val) {
100+
validate(key, val);
101+
102+
return db.upsert(key, val);
103+
}
104+
```
105+
106+
```mjs displayName="storage.test.mjs"
107+
import assert from 'node:assert/strict';
108+
import { describe, it } from 'node:test';
109+
110+
import { db } from 'db';
111+
112+
import { save } from './storage.mjs';
113+
114+
describe('storage', { concurrency: true }, () => {
115+
it('should retrieve the requested item', async () => {
116+
await db.upsert('good', 'item'); // give it something to read
117+
await db.upsert('erroneous', 'item'); // give it a chance to fail
118+
119+
const results = await read('a', true);
120+
121+
assert.equal(results.length, 1); // ensure read did not retrieve erroneous item
122+
123+
assert.deepEqual(results[0], { key: 'good', val: 'item' });
124+
});
125+
126+
it('should save the new item', async () => {
127+
const id = await save('good', 'item');
128+
129+
assert.ok(id);
130+
131+
const items = await db.getAll();
132+
133+
assert.equal(items.length, 1); // ensure save did not create duplicates
134+
135+
assert.deepEqual(items[0], { key: 'good', val: 'item' });
136+
});
137+
});
138+
```
139+
140+
In the above, the first and second cases (the `it()` statements) can sabotage each other because they are run concurrently and mutate the same store (a race condition): `save()`'s insertion can cause the otherwise valid `read()`'s test to fail its assertion on items found (and `read()`'s can do the same thing to `save()`'s).
141+
142+
## What to mock
143+
144+
### Modules + units
145+
146+
This leverages [`mock`](https://nodejs.org/api/test.html#class-mocktracker) from the Node.js test runner.
147+
148+
```mjs
149+
import assert from 'node:assert/strict';
150+
import { before, describe, it, mock } from 'node:test';
151+
152+
153+
describe('foo', { concurrency: true }, () => {
154+
let barMock = mock.fn();
155+
let foo;
156+
157+
before(async () => {
158+
const barNamedExports = await import('./bar.mjs')
159+
// discard the original default export
160+
.then(({ default, ...rest }) => rest);
161+
162+
// It's usually not necessary to manually call restore() after each
163+
// nor reset() after all (node does this automatically).
164+
mock.module('./bar.mjs', {
165+
defaultExport: barMock
166+
// Keep the other exports that you don't want to mock.
167+
namedExports: barNamedExports,
168+
});
169+
170+
// This MUST be a dynamic import because that is the only way to ensure the
171+
// import starts after the mock has been set up.
172+
({ foo } = await import('./foo.mjs'));
173+
});
174+
175+
it('should do the thing', () => {
176+
barMock.mockImplementationOnce(function bar_mock() {/**/});
177+
178+
assert.equal(foo(), 42);
179+
});
180+
});
181+
```
182+
183+
### APIs
184+
185+
A little-known fact is that there is a builtin way to mock `fetch`. [`undici`](https://github.com/nodejs/undici) is the Node.js implementation of `fetch`. It's shipped with `node`, but not currently exposed by `node` itself, so it must be installed (ex `npm install undici`).
186+
187+
```mjs displayName="endpoints.spec.mjs"
188+
import assert from 'node:assert/strict';
189+
import { beforeEach, describe, it } from 'node:test';
190+
import { MockAgent, setGlobalDispatcher } from 'undici';
191+
192+
import endpoints from './endpoints.mjs';
193+
194+
describe('endpoints', { concurrency: true }, () => {
195+
let agent;
196+
beforeEach(() => {
197+
agent = new MockAgent();
198+
setGlobalDispatcher(agent);
199+
});
200+
201+
it('should retrieve data', async () => {
202+
const endpoint = 'foo';
203+
const code = 200;
204+
const data = {
205+
key: 'good',
206+
val: 'item',
207+
};
208+
209+
agent
210+
.get('example.com')
211+
.intercept({
212+
path: endpoint,
213+
method: 'GET',
214+
})
215+
.reply(code, data);
216+
217+
assert.deepEqual(await endpoints.get(endpoint), {
218+
code,
219+
data,
220+
});
221+
});
222+
223+
it('should save data', async () => {
224+
const endpoint = 'foo/1';
225+
const code = 201;
226+
const data = {
227+
key: 'good',
228+
val: 'item',
229+
};
230+
231+
agent
232+
.get('example.com')
233+
.intercept({
234+
path: endpoint,
235+
method: 'PUT',
236+
})
237+
.reply(code, data);
238+
239+
assert.deepEqual(await endpoints.save(endpoint), {
240+
code,
241+
data,
242+
});
243+
});
244+
});
245+
```
246+
247+
### Time
248+
249+
Like Doctor Strange, you too can control time. You would usually do this just for convenience to avoid artificially protracted test runs (do you really want to wait 3 minutes for that `setTimeout()` to trigger?). You may also want to travel through time. This leverages [`mock.timers`](https://nodejs.org/api/test.html#class-mocktimers) from the Node.js test runner.
250+
251+
Note the use of time-zone here (`Z` in the time-stamps). Neglecting to include a consistent time-zone will likely lead to unexpected restults.
252+
253+
```mjs displayName="master-time.spec.mjs"
254+
import assert from 'node:assert/strict';
255+
import { describe, it, mock } from 'node:test';
256+
257+
import ago from './ago.mjs';
258+
259+
describe('whatever', { concurrency: true }, () => {
260+
it('should choose "minutes" when that\'s the closet unit', () => {
261+
mock.timers.enable({ now: new Date('2000-01-01T00:02:02Z') });
262+
263+
const t = ago('1999-12-01T23:59:59Z');
264+
265+
assert.equal(t, '2 minutes ago');
266+
});
267+
});
268+
```
269+
270+
This is especially useful when comparing against a static fixture (that is checked into a repository), such as in [snapshot testing](https://nodejs.org/api/test.html#snapshot-testing).

packages/i18n/locales/en.json

+2-1
Original file line numberDiff line numberDiff line change
@@ -109,7 +109,8 @@
109109
"links": {
110110
"testRunner": "Test Runner",
111111
"introduction": "Discovering Node.js's test runner",
112-
"usingTestRunner": "Using Node.js's test runner"
112+
"usingTestRunner": "Using Node.js's test runner",
113+
"mocking": "Mocking in tests"
113114
}
114115
}
115116
},

0 commit comments

Comments
 (0)