Skip to content
Node.js nd testing 4 min read

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 await is the cardinal sin of async testing. assert.equal(fetchUser(42).id, 42) reads .id off a pending promise (which is undefined) and the test passes for the wrong reason. If you await, 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.

RunnerRejection assertionResolution assertion
node:testassert.rejects(fn, matcher)assert.doesNotReject(fn)
Jestawait expect(fn()).rejects.toThrow(matcher)await expect(fn()).resolves.toBe(x)
Vitestawait 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 async and await the code under test — never assert against an unsettled promise.
  • Assert rejections with assert.rejects / expect(...).rejects rather than try/catch, so an unexpected resolution still fails the test.
  • Always await or return the assertion when it produces a promise; a dropped promise is a silent false pass.
  • Use fake timers to advance setTimeout/setInterval instantly instead of real await 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.once and util.promisify over manual done callbacks 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.
Last updated June 14, 2026
Was this helpful?