NodeJSMocking & Stubbing

Mocking & Stubbing

To unit-test a piece of code in isolation, you replace its dependencies — the database, an HTTP client, the clock — with controllable stand-ins. These stand-ins go by several names with subtle distinctions: spies record calls, stubs return canned values, mocks have pre-set expectations. Mocking is what makes unit tests fast and deterministic, but it's also the most over-used and mis-used testing technique. This page covers the vocabulary, the techniques across runners, and the crucial question of what and what not to mock.

Spy vs stub vs mock vs fake

Type

What it does

Spy

Records how a function was called; keeps the real implementation

Stub

Replaces a function with one that returns canned values

Mock

A stub with pre-set expectations that are verified

Fake

A working but simplified implementation (e.g. in-memory DB)

Spy = observe, Stub = control return value, Mock = control + verify expectations
The distinctions matter for clear thinking even though libraries blur them. A **spy** wraps a real function so you can assert it was called (with what arguments, how many times) while the real logic still runs. A **stub** replaces the function entirely with one returning values you dictate — you don't care how it's called, you care what it returns. A **mock** combines stubbing with verification ("expect this to be called exactly once with these args"). A **fake** is a lightweight real implementation, like an in-memory repository.
Dependency injection makes mocking clean

JS
// ❌ Hard to test — the dependency is imported and hardwired:
import { emailClient } from './emailClient.js'
export async function notify(userId) {
  const user = await db.users.findById(userId)   // hardwired db
  await emailClient.send(user.email, 'Hello')    // hardwired client
}

// ✅ Inject dependencies — now you can pass fakes in tests:
export async function notify(userId, { db, emailClient }) {
  const user = await db.users.findById(userId)
  await emailClient.send(user.email, 'Hello')
}

// In the test — pass stubs directly, no module mocking needed:
test('notify sends an email to the user', async () => {
  const db = { users: { findById: async () => ({ email: 'a@x.com' }) } }
  const emailClient = { send: mock.fn() }
  await notify(1, { db, emailClient })
  assert.equal(emailClient.send.mock.callCount(), 1)
})
Code designed for testability barely needs mocking libraries
When dependencies are **injected** (passed in as parameters) rather than imported and hardwired, you can supply plain object stubs in tests without any module-mocking machinery. This is the cleanest form of mocking and a strong argument for dependency injection as a design pattern. If you find yourself reaching for `jest.mock()` to replace a deeply-imported module, that's often a hint the code would benefit from injecting the dependency instead.
Mocking across the three runners

node:test

JS
import { test, mock } from 'node:test'
const fn = mock.fn(() => 'stubbed')
fn('a')
assert.equal(fn.mock.calls[0].arguments[0], 'a')

// Mock a method on a real object:
const obj = { save: () => 'real' }
mock.method(obj, 'save', () => 'fake')

Jest

JS
const fn = jest.fn().mockReturnValue('stubbed')
const asyncFn = jest.fn().mockResolvedValue({ id: 1 })
jest.spyOn(obj, 'save').mockReturnValue('fake')
jest.mock('./module.js')   // auto-mock entire module (hoisted)

Sinon (with Mocha)

JS
const stub = sinon.stub().returns('stubbed')
const asyncStub = sinon.stub().resolves({ id: 1 })
sinon.stub(obj, 'save').returns('fake')
sinon.restore()   // restore all stubs/spies after each test
Mocking time

JS
// Code that depends on the current time is hard to test deterministically:
function isExpired(token) {
  return token.expiresAt < Date.now()
}

// Jest fake timers:
jest.useFakeTimers().setSystemTime(new Date('2026-01-01'))
expect(isExpired({ expiresAt: new Date('2025-12-31').getTime() })).toBe(true)
jest.useRealTimers()

// node:test:
test('retries after delay', (t) => {
  t.mock.timers.enable({ apis: ['setTimeout', 'Date'] })
  // ...trigger code that schedules a timeout...
  t.mock.timers.tick(5000)   // advance 5s instantly — no real waiting
})
Fake timers let you test time-dependent logic instantly and deterministically
Real `setTimeout` and `Date.now()` make tests slow (waiting for real time) and flaky (results depend on when they run). Fake timers replace them so you can advance time programmatically — test that a retry fires after 5 seconds without waiting 5 real seconds, or test expiry logic at a fixed "now". Remember to restore real timers afterward (`useRealTimers`, automatic in `node:test` context mocks) so other tests aren't affected.
Mocking HTTP calls

JS
// nock intercepts outgoing HTTP — no real network call:
import nock from 'nock'

test('fetches weather from the API', async () => {
  nock('https://api.weather.com')
    .get('/v1/current?city=London')
    .reply(200, { temp: 15, condition: 'cloudy' })

  const weather = await getWeather('London')
  assert.equal(weather.temp, 15)
})

// Or inject a fake fetch:
const fakeFetch = mock.fn(async () => ({ json: async () => ({ temp: 15 }) }))
What NOT to mock
Don't mock what you don't own, and don't over-mock — you end up testing the mock
Two anti-patterns. First, **mocking what you don't own**: stubbing a third-party library's internals couples your test to that library's implementation, which can change. Prefer wrapping the library in your own thin adapter and mocking the adapter. Second, **over-mocking**: if you mock so much that the test only exercises mocks calling mocks, you're testing your mock setup, not your code — and the test passes even when the real integration is broken. The signal: a test that mocks everything and asserts mostly on `toHaveBeenCalled`. Mock external boundaries (DB, network, time); test real logic in between.
Guidelines
  • Mock at the boundary — database, network, filesystem, clock — not internal functions.

  • Prefer dependency injection over module mocking where you can.

  • Reset mocks between tests (afterEach) — leaked mock state causes flaky tests.

  • Don't mock the thing under test — only its collaborators.

  • For integration tests, use real (test) infrastructure — mock less, not more.

  • A test with only toHaveBeenCalled assertions often verifies nothing meaningful.

Next
Measure how much your tests actually exercise: [Code Coverage](/nodejs/test-coverage).