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:

typedescriptionexamplemock candidates
unitthe smallest bit of code you can isolateconst sum = (a, b) => a + bown code, external code, external system
componenta unit + dependenciesconst arithmetic = (op = sum, a, b) => ops[op](a, b)external code, external system
integrationcomponents fitting together-external code, external system
end-to-end (e2e)app + external data stores, delivery, etcA 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 foofoo from './foo.mjs';

export function function main(): voidmain() {
  const const f: anyf = import foofoo();
}

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 barbar from 'bar';

export function function main(): voidmain() {
  const const f: anyf = import barbar();
}

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 dbdb } from 'db';

export function function read(key: any, all?: boolean): anyread(key: anykey, all: booleanall = false) {
  validate(key: anykey, val);

  if (all: booleanall) {
    return import dbdb.getAll(key: anykey);
  }

  return import dbdb.getOne(key: anykey);
}

export function function save(key: any, val: any): anysave(key: anykey, val: anyval) {
  validate(key: anykey, val: anyval);

  return import dbdb.upsert(key: anykey, val: anyval);
}

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 assertassert from 'node:assert/strict';
import { import beforebefore, import describedescribe, import itit, import mockmock } from 'node:test';

import describedescribe('foo', { concurrency: booleanconcurrency: true }, () => {
  const const barMock: anybarMock = import mockmock.fn();
  let let foo: anyfoo;

  import beforebefore(async () => {
    const const barNamedExports: anybarNamedExports = 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.
@paramonfulfilled The callback to execute when the Promise is resolved.@paramonrejected The callback to execute when the Promise is rejected.@returnsA Promise for the completion of which ever callback is executed.
then
(({ default: _: any_, ...rest: anyrest }) => rest: anyrest);
// It's usually not necessary to manually call restore() after each // nor reset() after all (node does this automatically). import mockmock.module('./bar.mjs', { defaultExport: anydefaultExport: const barMock: anybarMock, // Keep the other exports that you don't want to mock. namedExports: anynamedExports: const barNamedExports: anybarNamedExports, }); // 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: anyfoo } = await import('./foo.mjs')); }); import itit('should do the thing', () => { const barMock: anybarMock.mock.mockImplementationOnce(function function (local function) bar_mock(): voidbar_mock() { /* … */ }); import assertassert.equal(let foo: anyfoo(), 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 assertassert from 'node:assert/strict';
import { import beforeEachbeforeEach, import describedescribe, import itit } from 'node:test';

import { import MockAgentMockAgent, import setGlobalDispatchersetGlobalDispatcher } from 'undici';

import import endpointsendpoints from './endpoints.mjs';

import describedescribe('endpoints', { concurrency: booleanconcurrency: true }, () => {
  let let agent: anyagent;
  import beforeEachbeforeEach(() => {
    let agent: anyagent = new import MockAgentMockAgent();
    import setGlobalDispatchersetGlobalDispatcher(let agent: anyagent);
  });

  import itit('should retrieve data', async () => {
    const const endpoint: "foo"endpoint = 'foo';
    const const code: 200code = 200;
    const 
const data: {
    key: string;
    val: string;
}
data
= {
key: stringkey: 'good', val: stringval: 'item', }; let agent: anyagent .get('https://example.com') .intercept({ path: stringpath: const endpoint: "foo"endpoint, method: stringmethod: 'GET', }) .reply(const code: 200code,
const data: {
    key: string;
    val: string;
}
data
);
import assertassert.deepEqual(await import endpointsendpoints.get(const endpoint: "foo"endpoint), { code: numbercode,
data: {
    key: string;
    val: string;
}
data
,
}); }); import itit('should save data', async () => { const const endpoint: "foo/1"endpoint = 'foo/1'; const const code: 201code = 201; const
const data: {
    key: string;
    val: string;
}
data
= {
key: stringkey: 'good', val: stringval: 'item', }; let agent: anyagent .get('https://example.com') .intercept({ path: stringpath: const endpoint: "foo/1"endpoint, method: stringmethod: 'PUT', }) .reply(const code: 201code,
const data: {
    key: string;
    val: string;
}
data
);
import assertassert.deepEqual(await import endpointsendpoints.save(const endpoint: "foo/1"endpoint), { code: numbercode,
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 assertassert from 'node:assert/strict';
import { import describedescribe, import itit, import mockmock } from 'node:test';

import import agoago from './ago.mjs';

import describedescribe('whatever', { concurrency: booleanconcurrency: true }, () => {
  import itit('should choose "minutes" when that\'s the closet unit', () => {
    import mockmock.timers.enable({ now: Datenow: new 
var Date: DateConstructor
new (value: number | string | Date) => Date (+4 overloads)
Date
('2000-01-01T00:02:02Z') });
const const t: anyt = import agoago('1999-12-01T23:59:59Z'); import assertassert.equal(const t: anyt, '2 minutes ago'); }); });

This is especially useful when comparing against a static fixture (that is checked into a repository), such as in snapshot testing.