Testing Asynchronous Code
An async test passes or fails on the promise it returns, and a missing await turns a real check into no check at all.
await assert.rejects(asyncFn[, error][, message])Most server code waits for something: a database, a file, another service. A test of that code has to wait too, and the runner needs to know it should.
Return the work
If the test function is async it returns a promise, and the runner waits for that promise. The test passes when it fulfils and fails when it rejects, so an AssertionError thrown inside an awaited call fails the test exactly as a synchronous one would. A callback style is also supported: declare a second parameter after the test context and the test ends when you call it, a truthy first argument meaning failure. Do not both return a promise and take that callback in one test — the runner treats it as a failure.
The forgotten await
assert.rejects() returns a promise. Without await in front of it, the test function finishes first and the assertion runs with nobody listening. If it would have failed, the test is still reported as passing, and the file is failed separately with a message about asynchronous activity after the test ended. If it would have passed, nothing is printed and nothing was actually checked. t.plan(n) catches this: the test fails when fewer than n context assertions ran.
Control the clock
Code with a timeout should not make the suite slow. mock.timers (added in Node.js 20.4.0, stable since Node.js 23.1.0) replaces setTimeout, setInterval and Date with versions you move by hand: enable({ apis: ['setTimeout'] }), then tick(ms) to run whatever is now due.
Example
When to use it
- Test a function that reads from a database without putting a sleep in the test.
- Prove that bad input makes an async function reject rather than resolve with undefined.
- Test a retry backoff or a daily job without the suite taking a day.
More examples
An async test, and asserting a rejection
The shape of nearly every service test: an async test function, awaited calls, and assert.rejects for the failure path. Note the two awaits in the second test — one for assert.rejects itself, and loadUser is called inside a function so the rejection is handed to assert rather than escaping.
import { test } from 'node:test';
import assert from 'node:assert/strict';
async function loadUser(repo, id) {
const row = await repo.findById(id);
if (!row) {
throw Object.assign(new Error('not found'), { code: 'NOT_FOUND' });
}
return { id: row.id, name: row.name };
}
const repo = {
async findById(id) {
return id === 7 ? { id: 7, name: 'ada' } : null;
},
};
test('loads a known user', async () => {
assert.deepStrictEqual(await loadUser(repo, 7), { id: 7, name: 'ada' });
});
test('rejects an unknown id', async () => {
// The await in front of assert.rejects is what makes this count.
await assert.rejects(() => loadUser(repo, 99), { code: 'NOT_FOUND' });
});A 24-hour timer, tested in milliseconds
Reach for this for anything scheduled: reminders, retry backoff, cache expiry, session timeouts. Faking the clock through the test context rather than the top-level mock means the real setTimeout is back in place for the next test without a cleanup hook. The same enable call also covers node:timers/promises.
import { test } from 'node:test';
import assert from 'node:assert/strict';
function scheduleReminder(afterMs, send) {
return setTimeout(send, afterMs); // in production this waits a day
}
test('sends the reminder after 24 hours', (t) => {
// t.mock.timers is restored when this test ends.
t.mock.timers.enable({ apis: ['setTimeout'] });
let sent = 0;
scheduleReminder(24 * 60 * 60 * 1000, () => { sent += 1; });
t.mock.timers.tick(23 * 60 * 60 * 1000);
assert.equal(sent, 0); // not due yet
t.mock.timers.tick(60 * 60 * 1000); // one more hour
assert.equal(sent, 1);
});What a forgotten await actually prints
Worth reading closely: the test itself is marked ok and the file is marked not ok, which is why this is confusing in CI. Had the escaped assertion happened to pass, there would be no message at all and no check. Any time you see that "asynchronous activity after the test ended" wording, look for a missing await.
import { test } from 'node:test';
import assert from 'node:assert/strict';
async function total() {
return 5;
}
// Wrong: the test function is not async and nothing waits for the promise.
test('wrong, yet reported as passing', () => {
total().then((value) => assert.equal(value, 99));
});
// Right: the runner waits for the promise the test returns.
test('actually checks', async () => {
assert.equal(await total(), 5);
});
// node --test prints, in part:
// ok 1 - wrong, yet reported as passing
// ok 2 - actually checks
// # Error: Test "wrong, yet reported as passing" generated asynchronous
// # activity after the test ended. This activity created the error
// # "AssertionError [ERR_ASSERTION]: 5 !== 99" and would have caused the
// # test to fail, but instead triggered an unhandledRejection event.
// not ok 1 - total.test.js
Discussion