Mocking in tests
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.
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.
Node.js provides many ways to mock various pieces of code.
This articles deals with the following types of tests:
type | description | example | mock candidates |
---|---|---|---|
unit | the smallest bit of code you can isolate | const sum = (a, b) => a + b | own code, external code, external system |
component | a unit + dependencies | const arithmetic = (op = sum, a, b) => ops[op](a, b) | external code, external system |
integration | components fitting together | - | external code, external system |
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) |
There are different schools of thought about when to mock and when not to mock, the broad strokes of which are outlined below.
When and not to mock
There are 3 main mock candidates:
- Own code
- External code
- External system
Own code
This is what your project controls.
import import foo
foo from './foo.mjs';
export function function main(): void
main() {
const const f: any
f = import foo
foo();
}
Here, foo
is an "own code" dependency of main
.
Why
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).
Why not
Mocking foo
can be more trouble than worth, especially when foo
is simple, well-tested, and rarely updated.
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.
External code
This is what your project does not control.
import import bar
bar from 'bar';
export function function main(): void
main() {
const const f: any
f = import bar
bar();
}
Here, bar
is an external package, e.g. an npm dependency.
Uncontroversially, for unit tests, this should always be mocked. For component and integration tests, whether to mock depends on what this is.
Why
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).
Why not
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).
External system
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.
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:
import { import db
db } from 'db';
export function function read(key: any, all?: boolean): any
read(key: any
key, all: boolean
all = false) {
validate(key: any
key, val);
if (all: boolean
all) {
return import db
db.getAll(key: any
key);
}
return import db
db.getOne(key: any
key);
}
export function function save(key: any, val: any): any
save(key: any
key, val: any
val) {
validate(key: any
key, val: any
val);
return import db
db.upsert(key: any
key, val: any
val);
}
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).
What to mock
Modules + units
This leverages mock
from the Node.js test runner.
import import assert
assert from 'node:assert/strict';
import { import before
before, import describe
describe, import it
it, import mock
mock } from 'node:test';
import describe
describe('foo', { concurrency: boolean
concurrency: true }, () => {
const const barMock: any
barMock = import mock
mock.fn();
let let foo: any
foo;
import before
before(async () => {
const const barNamedExports: any
barNamedExports = await import('./bar.mjs')
// discard the original default export
.Promise<any>.then<any, never>(onfulfilled?: ((value: any) => any) | null | undefined, onrejected?: ((reason: any) => PromiseLike<never>) | null | undefined): Promise<any>
Attaches callbacks for the resolution and/or rejection of the Promise.then(({ default: _: any
_, ...rest: any
rest }) => rest: any
rest);
// It's usually not necessary to manually call restore() after each
// nor reset() after all (node does this automatically).
import mock
mock.module('./bar.mjs', {
defaultExport: any
defaultExport: const barMock: any
barMock,
// Keep the other exports that you don't want to mock.
namedExports: any
namedExports: const barNamedExports: any
barNamedExports,
});
// This MUST be a dynamic import because that is the only way to ensure the
// import starts after the mock has been set up.
({ foo: any
foo } = await import('./foo.mjs'));
});
import it
it('should do the thing', () => {
const barMock: any
barMock.mock.mockImplementationOnce(function function (local function) bar_mock(): void
bar_mock() {
/* … */
});
import assert
assert.equal(let foo: any
foo(), 42);
});
});
APIs
A little-known fact is that there is a builtin way to mock fetch
. 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
).
import import assert
assert from 'node:assert/strict';
import { import beforeEach
beforeEach, import describe
describe, import it
it } from 'node:test';
import { import MockAgent
MockAgent, import setGlobalDispatcher
setGlobalDispatcher } from 'undici';
import import endpoints
endpoints from './endpoints.mjs';
import describe
describe('endpoints', { concurrency: boolean
concurrency: true }, () => {
let let agent: any
agent;
import beforeEach
beforeEach(() => {
let agent: any
agent = new import MockAgent
MockAgent();
import setGlobalDispatcher
setGlobalDispatcher(let agent: any
agent);
});
import it
it('should retrieve data', async () => {
const const endpoint: "foo"
endpoint = 'foo';
const const code: 200
code = 200;
const const data: {
key: string;
val: string;
}
data = {
key: string
key: 'good',
val: string
val: 'item',
};
let agent: any
agent
.get('https://example.com')
.intercept({
path: string
path: const endpoint: "foo"
endpoint,
method: string
method: 'GET',
})
.reply(const code: 200
code, const data: {
key: string;
val: string;
}
data);
import assert
assert.deepEqual(await import endpoints
endpoints.get(const endpoint: "foo"
endpoint), {
code: number
code,
data: {
key: string;
val: string;
}
data,
});
});
import it
it('should save data', async () => {
const const endpoint: "foo/1"
endpoint = 'foo/1';
const const code: 201
code = 201;
const const data: {
key: string;
val: string;
}
data = {
key: string
key: 'good',
val: string
val: 'item',
};
let agent: any
agent
.get('https://example.com')
.intercept({
path: string
path: const endpoint: "foo/1"
endpoint,
method: string
method: 'PUT',
})
.reply(const code: 201
code, const data: {
key: string;
val: string;
}
data);
import assert
assert.deepEqual(await import endpoints
endpoints.save(const endpoint: "foo/1"
endpoint), {
code: number
code,
data: {
key: string;
val: string;
}
data,
});
});
});
Time
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
from the Node.js test runner.
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.
import import assert
assert from 'node:assert/strict';
import { import describe
describe, import it
it, import mock
mock } from 'node:test';
import import ago
ago from './ago.mjs';
import describe
describe('whatever', { concurrency: boolean
concurrency: true }, () => {
import it
it('should choose "minutes" when that\'s the closet unit', () => {
import mock
mock.timers.enable({ now: Date
now: new var Date: DateConstructor
new (value: number | string | Date) => Date (+4 overloads)
Date('2000-01-01T00:02:02Z') });
const const t: any
t = import ago
ago('1999-12-01T23:59:59Z');
import assert
assert.equal(const t: any
t, '2 minutes ago');
});
});
This is especially useful when comparing against a static fixture (that is checked into a repository), such as in snapshot testing.