Testing Asynchronous Code
Most real Node.js code is asynchronous, and the single biggest source of flaky or silently-passing tests is mishandling that asynchrony. A test that forgets to await a promise can finish before its assertions run, reporting a false green. This page shows how to test async/await and promise-returning code, how to assert that something rejects, how to control time with fake timers, and how to sidestep the classic pitfalls. The examples use Node’s built-in test runner (node:test), but the patterns map directly onto Jest, Vitest, and Mocha.
Testing async/await and promises
The golden rule is simple: if the code under test returns a promise, your test must return or await that promise so the runner waits for it to settle. With async/await this is natural — make the test callback async and await the call.
// user-service.js
export async function fetchUser(id) {
const res = await fetch(`https://api.example.com/users/${id}`);
if (!res.ok) throw new Error(`HTTP ${res.status}`);
return res.json();
}
// user-service.test.js
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { fetchUser } from './user-service.js';
test('fetchUser returns the parsed user', async () => {
const user = await fetchUser(42);
assert.equal(user.id, 42);
assert.equal(typeof user.name, 'string');
});
Because the callback is async, the runner gets a promise back and waits for it. Any assertion failure or thrown error rejects that promise, and the test fails correctly.
Forgetting
awaitis the cardinal sin of async testing.assert.equal(fetchUser(42).id, 42)reads.idoff a pending promise (which isundefined) and the test passes for the wrong reason. If youawait, the comparison is against the resolved value.
Asserting that a promise rejects
You frequently need to prove that bad input fails. Do not wrap the call in a bare try/catch and assert inside the catch — if the promise unexpectedly resolves, the catch never runs and the test passes silently. Use the dedicated rejection assertions instead.
import { test } from 'node:test';
import assert from 'node:assert/strict';
test('rejects on a 404 response', async () => {
await assert.rejects(
() => fetchUser(999999),
{ message: 'HTTP 404' },
);
});
test('resolves for a valid id', async () => {
await assert.doesNotReject(() => fetchUser(1));
});
assert.rejects accepts a function (or a promise) and an optional matcher — an error class, a regex, a partial object, or a validator function. The matching runner equivalents are listed below.
| Runner | Rejection assertion | Resolution assertion |
|---|---|---|
node:test | assert.rejects(fn, matcher) | assert.doesNotReject(fn) |
| Jest | await expect(fn()).rejects.toThrow(matcher) | await expect(fn()).resolves.toBe(x) |
| Vitest | await expect(fn()).rejects.toThrow(matcher) | await expect(fn()).resolves.toEqual(x) |
Always await (or return) these — expect(...).rejects returns a promise, and dropping it is the same silent-pass bug in a new costume.
Faking timers for setTimeout and intervals
Code that uses setTimeout, setInterval, or debouncing should not make your test suite wait in real wall-clock time. Node’s built-in MockTimers (t.mock.timers) lets you advance a virtual clock instantly.
// debounce.js
export function debounce(fn, ms) {
let timer;
return (...args) => {
clearTimeout(timer);
timer = setTimeout(() => fn(...args), ms);
};
}
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { debounce } from './debounce.js';
test('debounce fires once after the delay', (t) => {
t.mock.timers.enable({ apis: ['setTimeout'] });
let calls = 0;
const debounced = debounce(() => calls++, 200);
debounced();
debounced();
debounced();
assert.equal(calls, 0); // nothing fired yet
t.mock.timers.tick(200); // jump 200ms forward
assert.equal(calls, 1); // collapsed to a single call
});
Output:
✔ debounce fires once after the delay (1.4ms)
ℹ tests 1
ℹ pass 1
ℹ fail 0
Timers are restored automatically when the test ends. In Jest use jest.useFakeTimers() with jest.advanceTimersByTime(200); in Vitest use vi.useFakeTimers() with vi.advanceTimersByTime(200) and vi.useRealTimers() in teardown. For promise-based delays (setTimeout from node:timers/promises), advance the clock after the awaiting code is suspended — call tick() on the next microtask, or use the runner’s runAllTimersAsync() equivalent.
Avoiding common async pitfalls
The callback (done) style still appears in older tutorials and event-based APIs. It works, but it is easy to misuse: call done() twice and the runner errors; forget to call it and the test hangs until it times out.
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { once } from 'node:events';
import { EventEmitter } from 'node:events';
// Prefer promisifying events over manual done callbacks
test('emits a ready event', async () => {
const bus = new EventEmitter();
setImmediate(() => bus.emit('ready', 'ok'));
const [payload] = await once(bus, 'ready');
assert.equal(payload, 'ok');
});
events.once turns a one-shot event into an awaitable promise, eliminating the done callback entirely. When you genuinely cannot avoid a callback API, convert it with node:util’s promisify so the rest of your test stays in async/await.
Best Practices
- Make every async test callback
asyncandawaitthe code under test — never assert against an unsettled promise. - Assert rejections with
assert.rejects/expect(...).rejectsrather thantry/catch, so an unexpected resolution still fails the test. - Always
awaitorreturnthe assertion when it produces a promise; a dropped promise is a silent false pass. - Use fake timers to advance
setTimeout/setIntervalinstantly instead of realawait sleep()delays that slow and destabilise the suite. - Restore real timers in teardown (the built-in runner does this for you; Jest/Vitest need an explicit reset).
- Prefer
events.onceandutil.promisifyover manualdonecallbacks to keep tests linear and timeout-proof. - Give async tests a sensible per-test timeout so a hung promise fails fast with a clear message instead of stalling CI.