Mocks, Stubs and Isolation

mock.fn and mock.method replace one function with a recorded stand-in, so a unit test never opens a database connection.

Syntaxmock.method(object, methodName[, implementation][, options])

A unit test should fail for one reason: the code under test is wrong. If the test also needs a database, it can fail because the database is down, slow, or holds different rows than last week. A test double is a stand-in for a dependency that the test controls. A spy records the calls made to it. A stub also returns a value you chose.

mock.fn and mock.method

mock.fn([original[, implementation]][, options]) returns a new function that records every call to it. mock.method(object, methodName[, implementation][, options]) replaces a method that already exists on an object, keeping the original so it can be put back. Both were added in Node.js 19.1.0 and backported to Node.js 18.13.0.

What was recorded lives on the function's .mock property: callCount() for the number of calls, calls for one entry per call with its arguments and result, resetCalls() to clear that history, and restore() to put the original back. mockImplementationOnce() changes the behaviour for a single call, which is how a retry gets tested.

Isolation without mocking

Mocks are not the only route. If a function receives its database as an argument instead of importing one, the test passes a plain object with the one or two methods that function uses, and no mocking is involved at all. That is usually the clearer test. Reach for mock.method when the dependency is already wired in and you cannot pass it.

Example

Try it yourself
Loading editor…
Press Run to execute the code.

When to use it

  • Test an order service's arithmetic without a running database.
  • Check that a webhook is sent once with the right payload, without sending anything.
  • Make the retry branch run in a test by failing only the first call.

More examples

Inject the dependency instead of mocking it

No mock library, no patching, and the test cannot accidentally talk to a real database because the code has no way to find one. Write functions this way wherever you can; keep the pool creation in one module that only the entry point imports.

Example · javascript
// service.js — db arrives as an argument, so nothing here imports a driver
export async function monthlyTotal(db, customerId) {
  // The real db is a connection pool; this is the line production runs:
  // pool.query('SELECT amount FROM charges WHERE customer_id = $1', [id])
  const rows = await db.query(
    'SELECT amount FROM charges WHERE customer_id = $1',
    [customerId],
  );
  return rows.reduce((sum, row) => sum + row.amount, 0);
}

// service.test.js — a plain object with the one method the function uses
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { monthlyTotal } from './service.js';

const db = {
  async query() {
    return [{ amount: 1200 }, { amount: 300 }];
  },
};

test('adds up the month', async () => {
  assert.equal(await monthlyTotal(db, 7), 1500);
});

Mocking a method that is already wired in

When the module under test imports its collaborator directly, patching the method is the way in. Asserting on calls[0].arguments checks the payload, not just that something was sent — and no mail leaves the machine. Prefer t.mock over the top-level mock so the real method is back for the next test.

Example · javascript
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { mailer } from './mailer.js'; // imported by the module under test
import { signUp } from './signup.js';

test('sends exactly one welcome email', async (t) => {
  // t.mock is restored automatically when this test ends.
  const send = t.mock.method(mailer, 'send', async () => ({ id: 'queued' }));

  await signUp({ email: '[email protected]' });

  assert.equal(send.mock.callCount(), 1);
  const [message] = send.mock.calls[0].arguments; // the first argument of call 1
  assert.equal(message.to, '[email protected]');
  assert.match(message.subject, /welcome/i);
});

Failing the first call only, to test a retry

A retry path is hard to reach with a real dependency, because you cannot ask a real service to fail once. mockImplementationOnce makes that the easy case. The same trick covers a cache miss followed by a hit, or a token that expires on first use.

Example · javascript
import { test } from 'node:test';
import assert from 'node:assert/strict';

async function withRetry(call) {
  try {
    return await call();
  } catch {
    return await call(); // one retry
  }
}

test('retries once after a failed call', async (t) => {
  const call = t.mock.fn(async () => 'second');
  // The first call rejects; later calls use the implementation above.
  call.mock.mockImplementationOnce(async () => {
    throw new Error('ECONNRESET');
  });

  assert.equal(await withRetry(call), 'second');
  assert.equal(call.mock.callCount(), 2);
});

Discussion

  • Be the first to comment on this lesson.