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) |
Dependency injection makes mocking clean
// ❌ 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)
})Mocking across the three runners
node:test
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
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)
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 testMocking time
// 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
})Mocking HTTP calls
// 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
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
toHaveBeenCalledassertions often verifies nothing meaningful.