Node.js 71 🟢 Mocking, Spying, and Timers in node:test
The node:test module includes a mocking system built into the runtime. It provides spies, stubs, method replacement, and fake timers without requiring Sinon, Jest, or any other third-party library. The mocking API is exposed through the mock object and through the TestContext object that every test function receives. The distinction between these two entry points matters more than it might seem: mocks created through the global mock object persist until explicitly reset, while mocks created through t.mock are automatically restored when the test finishes . The test-context path is the safer default because it eliminates the most common source of test pollution—a mock that leaks into the next test.
The mocking primitives map to three distinct testing needs. Spies wrap a function to record its calls, arguments, results, and errors without changing its behavior. Stubs replace a function’s implementation, optionally returning a fixed value or throwing a fixed error. Method mocks replace a method on an existing object and record every call through that method. The timer API is a separate subsystem that intercepts setTimeout, setInterval, setImmediate, and Date, allowing tests to advance time instantly instead of waiting for real timers . Together, these primitives cover the mocking needs of most Node.js unit tests.
Key point: mock.fn() creates a spy or stub, t.mock.method() replaces an object method with automatic restoration, and t.mock.timers.enable() intercepts timers so t.mock.timers.tick(ms) can advance time instantly; use the t.mock variants over the global mock object so cleanup happens automatically.
Why the context-scoped mock API is the correct default
The test pollution problem. A mock created with the global mock object and never reset remains in place for the rest of the process. The next test that calls the same function receives the mocked behavior instead of the real one, and the failure is confusing because the test itself looks correct. The t.mock API solves this by tying the mock’s lifetime to the test’s lifetime. When the test finishes—whether it passed or failed—the runner restores the original behavior automatically . This is not a convenience; it is a correctness requirement for any test suite that runs more than one test per file.
The spy versus stub distinction. mock.fn() accepts an optional implementation function. If you pass one, the returned function is a stub: it calls your implementation and records the call. If you pass nothing, the returned function is a spy: it records the call and returns undefined. The same object supports both, and the recording mechanism is identical. The mock property on the returned function exposes callCount(), calls (an array of call records), and mockImplementation() for changing the behavior after creation .
The method replacement problem. Replacing an object’s method by assignment (obj.method = mock.fn()) works, but restoring the original requires saving it first. t.mock.method(obj, 'name') does the save and restore automatically, and it records the this context of each call. The call record includes arguments, result, error, this, and target, which is the function being mocked .
The timer problem. A test that verifies a debounce function waits for 500ms of real time. A test that verifies a retry with exponential backoff waits for seconds. The timer mock replaces the real timer functions with a fake clock that only advances when the test calls tick() or runAll(). A test that would take ten seconds of wall-clock time completes in milliseconds, and the timing is deterministic instead of dependent on machine load .
a. Spies and stubs with mock.fn()
mock.fn() creates a function that records every call. The returned function has a mock property that exposes the recorded data and the methods for changing behavior.
import { test } from 'node:test';
import assert from 'node:assert';
test('spy records calls', (t) => {
const spy = t.mock.fn((a, b) => a + b);
assert.strictEqual(spy.mock.callCount(), 0);
assert.strictEqual(spy(3, 4), 7);
assert.strictEqual(spy.mock.callCount(), 1);
const call = spy.mock.calls[0];
assert.deepStrictEqual(call.arguments, [3, 4]);
assert.strictEqual(call.result, 7);
assert.strictEqual(call.error, undefined);
});
The calls array contains one entry per invocation. Each entry has arguments (the arguments passed), result (the return value, or undefined if the function threw), and error (the thrown error, or undefined if it did not throw) . This record is what makes a spy useful: it answers “was this called, with what arguments, and what happened?”
A stub is the same object with a different intent. The implementation passed to mock.fn() determines the behavior. mockImplementation() changes it after creation, and mockImplementationOnce() changes it for exactly one call before reverting to the original implementation .
test('stub changes implementation', (t) => {
const greet = t.mock.fn(() => 'default');
assert.strictEqual(greet(), 'default');
greet.mock.mockImplementationOnce(() => 'override');
assert.strictEqual(greet(), 'override');
assert.strictEqual(greet(), 'default');
});
The mockImplementationOnce pattern is useful when a test needs to simulate a sequence of responses: a failure followed by a success, a cache miss followed by a cache hit, or an empty result followed by a populated result.
b. Method mocking with t.mock.method()
t.mock.method(object, methodName, implementation?) replaces object[methodName] with a mocked function. If no implementation is provided, the original method is still called, but the call is recorded. If an implementation is provided, the original method is not called, and the implementation’s return value is used instead.
test('spies on an object method', (t) => {
const number = {
value: 5,
add(a) {
return this.value + a;
},
};
t.mock.method(number, 'add');
assert.strictEqual(number.add.mock.callCount(), 0);
assert.strictEqual(number.add(3), 8);
assert.strictEqual(number.add.mock.callCount(), 1);
const call = number.add.mock.calls[0];
assert.deepStrictEqual(call.arguments, [3]);
assert.strictEqual(call.result, 8);
assert.strictEqual(call.this, number);
});
The call.this field is what distinguishes method mocking from plain function mocking. The spy records the object the method was called on, which is essential for verifying that a method uses the correct context. The call.target field is undefined for method mocks because the mock is attached to the object, not to a standalone function .
t.mock.method() restores the original method when the test finishes. It also supports mock.restore() for manual restoration within the test, which is useful when the test needs to verify behavior after the mock is removed.
c. Timer mocking with t.mock.timers
The timer API is enabled per test with t.mock.timers.enable(). The apis option specifies which timer functions to mock: 'setTimeout', 'setInterval', 'setImmediate', and 'Date'. If apis is omitted, all of them are mocked .
test('mocks setTimeout', (t) => {
t.mock.timers.enable({ apis: ['setTimeout'] });
const fn = t.mock.fn();
setTimeout(fn, 9999);
assert.strictEqual(fn.mock.callCount(), 0);
t.mock.timers.tick(9999);
assert.strictEqual(fn.mock.callCount(), 1);
});
The tick(ms) method advances the fake clock by the specified number of milliseconds and executes any timers that are due. The runAll() method executes every pending timer immediately and advances the clock to the time of the last timer . The clear functions—clearTimeout, clearInterval, clearImmediate—are implicitly mocked when their corresponding timer is enabled. A cleared timer does not fire when tick() is called .
When 'Date' is included in apis, the Date constructor and Date.now() are also mocked. The initial time is the Unix epoch (0) unless the now option is provided. tick() advances the mocked Date along with the timers, so Date.now() reflects the elapsed fake time .
test('mocks Date', (t) => {
t.mock.timers.enable({ apis: ['Date'], now: 1000 });
assert.strictEqual(Date.now(), 1000);
t.mock.timers.tick(500);
assert.strictEqual(Date.now(), 1500);
});
The setTime(ms) method moves the fake clock to an absolute time without executing timers that were scheduled in the past. This is useful for testing code that compares timestamps without triggering the timers themselves . The distinction between tick() and setTime() is important: tick() simulates time passing and fires due timers, while setTime() changes the clock’s reading without firing anything.
A limitation worth knowing: destructuring timer functions (import { setTimeout } from 'node:timers') is not supported by the timer mock. The mock intercepts the global and module-level timer functions, but a destructured binding holds a reference to the original function, and the mock does not replace it . Use nodeTimers.setTimeout(...) instead of destructuring to ensure the mock applies.
Complete Example Session
The following session tests a debounce function using a stub for the callback and mocked timers, then tests an object method using t.mock.method.
// ============================================
// PART 1: THE MODULE UNDER TEST
// ============================================
// debounce.js
export function debounce(fn, delay) {
let timer;
return (...args) => {
clearTimeout(timer);
timer = setTimeout(() => fn(...args), delay);
};
}
// ============================================
// PART 2: TESTING DEBOUNCE WITH MOCKED TIMERS
// ============================================
import { test } from 'node:test';
import assert from 'node:assert';
import { debounce } from './debounce.js';
test('debounce calls the callback once after the delay', (t) => {
t.mock.timers.enable({ apis: ['setTimeout'] });
const callback = t.mock.fn();
const debounced = debounce(callback, 500);
debounced('first');
debounced('second');
debounced('third');
assert.strictEqual(callback.mock.callCount(), 0);
t.mock.timers.tick(499);
assert.strictEqual(callback.mock.callCount(), 0);
t.mock.timers.tick(1);
assert.strictEqual(callback.mock.callCount(), 1);
assert.deepStrictEqual(callback.mock.calls[0].arguments, ['third']);
});
// ============================================
// PART 3: TESTING AN OBJECT METHOD
// ============================================
// user-service.js
export class UserService {
constructor(repo) {
this.repo = repo;
}
async getDisplayName(id) {
const user = await this.repo.findById(id);
return `${user.firstName} ${user.lastName}`;
}
}
// ============================================
// PART 4: METHOD MOCK TEST
// ============================================
test('getDisplayName calls repo.findById', async (t) => {
const repo = {
findById: async (id) => ({ firstName: 'Alice', lastName: 'Smith' }),
};
t.mock.method(repo, 'findById', async (id) => ({
firstName: 'Mocked',
lastName: 'User',
}));
const service = new UserService(repo);
const name = await service.getDisplayName('user-1');
assert.strictEqual(name, 'Mocked User');
assert.strictEqual(repo.findById.mock.callCount(), 1);
assert.deepStrictEqual(repo.findById.mock.calls[0].arguments, ['user-1']);
assert.strictEqual(repo.findById.mock.calls[0].this, repo);
});
// ============================================
// PART 5: STUB SEQUENCE
// ============================================
test('retry logic handles failure then success', (t) => {
const fetchData = t.mock.fn();
fetchData.mock.mockImplementationOnce(() => {
throw new Error('Network error');
});
fetchData.mock.mockImplementationOnce(() => ({ data: 'ok' }));
assert.throws(() => fetchData(), /Network error/);
assert.deepStrictEqual(fetchData(), { data: 'ok' });
assert.strictEqual(fetchData.mock.callCount(), 2);
});
// ============================================
// PART 6: TIMER WITH DATE
// ============================================
test('rate limiter allows one request per second', (t) => {
t.mock.timers.enable({ apis: ['setTimeout', 'Date'], now: 0 });
let lastCall = 0;
function allowed() {
const now = Date.now();
if (now - lastCall < 1000) return false;
lastCall = now;
return true;
}
assert.strictEqual(allowed(), true);
t.mock.timers.tick(500);
assert.strictEqual(allowed(), false);
t.mock.timers.tick(500);
assert.strictEqual(allowed(), true);
});
The six parts cover the debounce module, a timer mock test that verifies the callback fires once with the last argument, a user service class, a method mock test that verifies the repository method is called with the correct arguments and context, a stub sequence test, and a timer test that combines setTimeout and Date. What the session demonstrates is that the t.mock API handles spies, stubs, method replacement, and timers within a single test context, and the automatic restoration means no mock leaks into the next test.
Quick Reference
Mock Creation
| Method | Purpose | Auto-Restore |
|---|---|---|
mock.fn(impl?) | Standalone spy or stub | No (global) |
t.mock.fn(impl?) | Standalone spy or stub | Yes |
t.mock.method(obj, name, impl?) | Replace object method | Yes |
Spy Inspection
| Property/Method | Returns |
|---|---|
spy.mock.callCount() | Number of calls |
spy.mock.calls | Array of call records |
call.arguments | Arguments array |
call.result | Return value |
call.error | Thrown error |
call.this | this context (method mocks) |
Stub Control
| Method | Effect |
|---|---|
mockImplementation(fn) | Replace implementation |
mockImplementationOnce(fn) | Replace for one call |
mock.restore() | Restore original |
Timer API
| Method | Effect |
|---|---|
t.mock.timers.enable({ apis }) | Enable timer mocking |
t.mock.timers.tick(ms) | Advance time, fire due timers |
t.mock.timers.runAll() | Fire all pending timers |
t.mock.timers.setTime(ms) | Set absolute time, no firing |
t.mock.timers.reset() | Restore real timers |
Timer APIs
| API | Mocked Functions |
|---|---|
'setTimeout' | setTimeout, clearTimeout |
'setInterval' | setInterval, clearInterval |
'setImmediate' | setImmediate, clearImmediate |
'Date' | Date, Date.now() |
Best Practices
✅ Do This:
t.mock.method(obj, 'method'); // ✅ auto-restore
t.mock.timers.enable({ apis: ['setTimeout'] }); // ✅ explicit APIs
t.mock.timers.tick(500); // ✅ advance time
assert.deepStrictEqual(spy.mock.calls[0].arguments, [x]); // ✅ verify args
❌ Don’t Do This:
mock.fn() // without mock.reset() // ❌ leaks globally
obj.method = mock.fn() // manual replacement // ❌ no auto-restore
import { setTimeout } from 'node:timers' // ❌ mock may not apply
// real setTimeout in test // ❌ slow and flaky
Common Pitfalls
| Pitfall | Why It Happens | Fix |
|---|---|---|
| Mock leaks to next test | Used global mock.fn() without reset | Use t.mock.fn() |
| Timer mock ignored | Destructured timer function | Use nodeTimers.setTimeout |
Date.now() not mocked | 'Date' not in apis | Add 'Date' to enable options |
this is undefined | Used mock.fn() for method | Use t.mock.method() |
| Stub sequence wrong | Used mockImplementation instead of Once | mockImplementationOnce |
Real-World Examples
1. Spy on a callback
const callback = t.mock.fn();
debounced('x');
t.mock.timers.tick(500);
assert.strictEqual(callback.mock.callCount(), 1);
2. Stub a repository
t.mock.method(repo, 'findById', async () => ({ id: 1, name: 'Mock' }));
3. Stub sequence
fetch.mock.mockImplementationOnce(() => { throw new Error('fail'); });
fetch.mock.mockImplementationOnce(() => ({ ok: true }));
4. Mock setTimeout
t.mock.timers.enable({ apis: ['setTimeout'] });
t.mock.timers.tick(1000);
5. Mock Date
t.mock.timers.enable({ apis: ['Date'], now: 1000 });
assert.strictEqual(Date.now(), 1000);
6. Mock interval
t.mock.timers.enable({ apis: ['setInterval'] });
t.mock.timers.tick(3000);
7. Run all timers
t.mock.timers.runAll();
8. Clear timer
const id = setTimeout(fn, 1000);
clearTimeout(id);
t.mock.timers.tick(1000);
assert.strictEqual(fn.mock.callCount(), 0);
9. Set absolute time
t.mock.timers.setTime(5000);
10. Restore method
const spy = t.mock.method(obj, 'method');
spy.mock.restore();
Visual
Mock Lifecycle
┌──────────────────────────────────────────────┐
│ Test starts │
│ │ │
│ ▼ │
│ t.mock.fn() / t.mock.method() │
│ t.mock.timers.enable() │
│ │ │
│ ▼ │
│ Test body runs, assertions on .mock │
│ │ │
│ ▼ │
│ Test ends (pass or fail) │
│ │ │
│ ▼ │
│ Runner automatically restores: │
│ - original function behavior │
│ - original method on object │
│ - real timers │
└──────────────────────────────────────────────┘
Spy vs Stub
┌──────────────────────────────────────────────┐
│ SPY (no implementation) │
│ const spy = t.mock.fn(); │
│ spy(1, 2); // returns undefined │
│ spy.mock.callCount() === 1 │
│ Records, does not change behavior │
│ │
│ STUB (with implementation) │
│ const stub = t.mock.fn(() => 42); │
│ stub(); // returns 42 │
│ stub.mock.callCount() === 1 │
│ Records and changes behavior │
└──────────────────────────────────────────────┘
Timer Tick vs RunAll
┌──────────────────────────────────────────────┐
│ setTimeout(fn, 1000); │
│ setTimeout(fn, 2000); │
│ │
│ tick(1000): │
│ fires first timer, second still pending │
│ │
│ runAll(): │
│ fires both, clock advances to 2000 │
└──────────────────────────────────────────────┘
Method Mock Context
┌──────────────────────────────────────────────┐
│ const obj = { value: 5, add(a) { return this.value + a; } }; │
│ t.mock.method(obj, 'add'); │
│ obj.add(3); │
│ │
│ call = obj.add.mock.calls[0]; │
│ call.arguments → [3] │
│ call.this → obj ← preserved context │
│ call.result → 8 │
└──────────────────────────────────────────────┘
Summary
| Item | Value |
|---|---|
| Mock API | node:test built-in |
| Global entry | mock (requires manual reset) |
| Context entry | t.mock (auto-restore) |
| Spy | mock.fn() |
| Stub | mock.fn(impl) |
| Method mock | t.mock.method(obj, name) |
| Timer mock | t.mock.timers.enable() |
| Advance time | tick(ms) |
| Fire all | runAll() |
| Auto-restore | TestContext mocks only |
Key takeaways:
- Use
t.mockover the globalmock. The context-scoped API restores every mock when the test finishes, preventing pollution of subsequent tests . mock.fn()creates a spy by default. Pass an implementation to make it a stub.mockImplementationOncechanges the behavior for one call before reverting .t.mock.method()records thethiscontext. Thecall.thisfield is what distinguishes method mocking from standalone function mocking .- Timer mocking makes time-dependent tests instant.
tick(ms)advances the fake clock and fires due timers.runAll()fires everything at once . 'Date'must be explicitly enabled. MockingsetTimeoutdoes not mockDate. Add'Date'to theapisarray and use thenowoption to set the initial time .- Clear functions are implicitly mocked.
clearTimeoutandclearIntervalwork with the mocked timers without additional configuration . - Destructured timers bypass the mock. Use
nodeTimers.setTimeoutinstead ofconst { setTimeout } = require('node:timers')to ensure the mock applies .
Remember: The node:test mocking system is not as feature-rich as Sinon or Jest’s mock API, but it covers the core needs: spies, stubs, method replacement, and fake timers. The critical discipline is using the t.mock context so cleanup is automatic. A test suite that mocks the global mock object and forgets to reset will pass individually and fail in sequence—the worst kind of failure because it depends on execution order. Use t.mock.fn() for standalone functions, t.mock.method() for object methods, and t.mock.timers for anything that waits. The test that takes ten seconds of real time becomes a test that takes ten milliseconds of fake time, and the assertions are the same either way.
Stop using slow, ad-bloated tool sites! 🤮
🔎 Search “KandZ Tools” on Google to use many professional utilities for free.
KandZ.me is the ultimate minimalist hub for:
✅ Finance (Mortgage, Interest, Inflation)
✅ Tech (Base64, JSON, Dev Suite, IP)
✅ Health (BMI, BMR, TDEE)
✅ Productivity (Timer, Workspace, QR)
⚡️ Fast & Private
🔒 No data leaves your device
💎 100% Free
🔗 Use it now: https://tools.kandz.me
🔖 Bookmark it—you’ll need it later!