Skip to content
Node.js nd testing 5 min read

Mocking, Stubbing & Spies

Real applications talk to databases, payment gateways, file systems, and remote APIs. Exercising those dependencies in a unit test makes it slow, flaky, and dependent on the outside world. Test doubles solve this by standing in for real collaborators so you can isolate the code under test, control its inputs, and assert on how it interacts with the things around it. This page covers the four kinds of doubles and how to create them with the built-in node:test mock API, Jest, and Sinon.

The four kinds of test doubles

The terms get used loosely, but they describe distinct behaviors. Picking the right one keeps tests focused on what actually matters.

DoublePurposeRecords calls?Replaces behavior?
SpyObserve calls to a real function without changing itYesNo
StubReplace a function with canned return valuesYesYes
MockA stub plus built-in expectations about how it was calledYesYes
FakeA working but simplified implementation (e.g. in-memory DB)NoYes

In practice, modern frameworks blur the line between spy, stub, and mock: the same object both records calls and lets you program its return value. The distinction is mostly about intent — are you observing, replacing, or asserting?

Mocking with the built-in node:test API

Node’s test runner ships a first-class mocking API on the mock object exposed to each test. mock.fn() creates a spy/stub, and its .mock property records every call.

import { test, mock } from 'node:test';
import assert from 'node:assert/strict';

test('mock.fn records calls and returns a stubbed value', () => {
  const send = mock.fn((to, body) => `sent:${to}`);

  send('[email protected]', 'hi');
  send('[email protected]', 'hello');

  assert.equal(send.mock.callCount(), 2);
  assert.deepEqual(send.mock.calls[0].arguments, ['[email protected]', 'hi']);
  assert.equal(send.mock.calls[1].result, 'sent:[email protected]');
});

To replace a method on an existing object, use mock.method(). It patches the property in place and gives you the same recording surface. Pair it with t.after() or call mock.restore() so the original is reinstated.

import { test, mock } from 'node:test';
import assert from 'node:assert/strict';

const billing = {
  charge(amount) {
    throw new Error('do not call the real gateway in tests');
  },
};

test('mock.method stubs a method and restores it', (t) => {
  t.after(() => mock.restoreAll());

  mock.method(billing, 'charge', () => ({ id: 'ch_123', status: 'paid' }));

  const result = billing.charge(4200);
  assert.equal(result.status, 'paid');
  assert.equal(billing.charge.mock.callCount(), 1);
});

The mock object passed into each test (via t.mock) is automatically reset after the test finishes. The module-level mock import is shared, so restore it yourself with mock.restoreAll().

Mocking an entire module

mock.module() (available since Node 20.6 behind --experimental-test-module-mocks) intercepts imports so you can replace a dependency wholesale.

node --experimental-test-module-mocks --test
import { test, mock } from 'node:test';
import assert from 'node:assert/strict';

test('replaces a module dependency', async () => {
  mock.module('./mailer.js', {
    namedExports: { sendEmail: mock.fn(async () => ({ ok: true })) },
  });

  const { registerUser } = await import('./service.js');
  const result = await registerUser('[email protected]');

  assert.equal(result.emailed, true);
});

Mocking with Jest

Jest’s jest.fn() is the equivalent spy/stub, and its matchers read fluently. jest.mock() auto-replaces a module path with mock functions.

import { jest } from '@jest/globals';
import { fetchUser } from './service.js';

jest.mock('./api.js'); // every export becomes a jest.fn()
import * as api from './api.js';

test('returns the user from the API client', async () => {
  api.getUser.mockResolvedValue({ id: 7, name: 'Ada' });

  const user = await fetchUser(7);

  expect(user.name).toBe('Ada');
  expect(api.getUser).toHaveBeenCalledWith(7);
  expect(api.getUser).toHaveBeenCalledTimes(1);
});

Use jest.spyOn(object, 'method') when you want to watch a real method (optionally overriding it with .mockImplementation()), and jest.restoreAllMocks() to undo spies between tests.

Mocking with Sinon

Sinon is framework-agnostic and works with any runner. It cleanly separates spies, stubs, and mocks, which makes the vocabulary explicit.

import sinon from 'sinon';
import assert from 'node:assert/strict';
import { test, afterEach } from 'node:test';

const repo = { findById: async (id) => { throw new Error('real DB'); } };

afterEach(() => sinon.restore());

test('stub returns canned data and the spy records the call', async () => {
  const stub = sinon.stub(repo, 'findById').resolves({ id: 1, name: 'Ada' });

  const user = await repo.findById(1);

  assert.equal(user.name, 'Ada');
  assert.ok(stub.calledOnceWithExactly(1));
});

Sinon’s sinon.fake(), sinon.stub(), and sinon.mock() map directly onto the doubles table above. sinon.restore() resets everything patched on real objects in one call.

Mocking a dependency end to end

Suppose a service charges a card and emails a receipt. We stub the gateway to avoid real charges and spy on the mailer to assert it was invoked.

import { test, mock } from 'node:test';
import assert from 'node:assert/strict';

function createCheckout({ gateway, mailer }) {
  return async function checkout(email, cents) {
    const charge = await gateway.charge(cents);
    await mailer.send(email, `Receipt ${charge.id}`);
    return charge;
  };
}

test('checkout charges then emails', async () => {
  const gateway = { charge: mock.fn(async () => ({ id: 'ch_9' })) };
  const mailer = { send: mock.fn(async () => {}) };

  const checkout = createCheckout({ gateway, mailer });
  const result = await checkout('[email protected]', 4200);

  assert.equal(result.id, 'ch_9');
  assert.deepEqual(gateway.charge.mock.calls[0].arguments, [4200]);
  assert.deepEqual(mailer.send.mock.calls[0].arguments, [
    '[email protected]',
    'Receipt ch_9',
  ]);
});

Output:

✔ checkout charges then emails (1.842ms)
ℹ tests 1
ℹ pass 1
ℹ fail 0

Note that the service takes its collaborators as arguments (dependency injection). This is the single biggest factor in making code easy to mock — no module patching required.

Best practices

  • Prefer dependency injection over module mocking; passing collaborators in makes substitution trivial and your design more decoupled.
  • Mock at the edges of your system (network, DB, clock), not your own pure logic — over-mocking tests the mocks, not the code.
  • Always restore doubles after each test (mock.restoreAll(), jest.restoreAllMocks(), sinon.restore()) to prevent leakage between tests.
  • Assert on behavior and outputs first; only assert on call counts and arguments when the interaction itself is the contract you care about.
  • Use a spy when you just need to observe, a stub when you need a canned return, and a fake (e.g. in-memory store) when the collaborator has meaningful state.
  • Keep mock setups close to the assertions they support so a failing test is easy to read.
Last updated June 14, 2026
Was this helpful?