Async tests that pass for the wrong reason
A green async test proves the assertions ran and passed. Plenty of async tests only prove the first half — and some prove neither.
Train thisThe problem
A test asserts that an expired coupon is rejected. It has been green since the day it was written.
Someone then changes applyCoupon so expired coupons resolve with a warning instead of rejecting. The test stays green.
import test from 'node:test';
import assert from 'node:assert/strict';
test('rejects an expired coupon', () => {
assert.rejects(applyCoupon('EXPIRED'));
});PREDICT FIRST
applyCoupon now resolves instead of rejecting. What does this test do?
Commit to an answer before you read on. Being wrong here is the part that sticks.
Why it behaves that way
The test function is synchronous. It calls assert.rejects, gets a promise back, discards it, and returns. The runner sees a function that returned without throwing, which is its definition of success.
The rule is narrow and worth stating exactly: an async test must return or await every promise it creates. await assert.rejects(...) is correct; so is return assert.rejects(...). Bare assert.rejects(...) is a floating promise, and floating promises in tests are silent.
The second failure mode is subtler. Tests that need two operations to interleave in a specific order often reach for await delay(10) to make it happen. That is not a test of ordering, it is a bet on scheduling: it is slow when it passes and flaky when the machine is busy, and it fails to distinguish "the newer result won" from "both happened to finish in the order I hoped".
Control the timing instead. Hand out deferred promises you resolve by hand, and the interleaving becomes an explicit part of the test rather than a property of the host. The test is then both instant and deterministic — and it can express orderings a real network would produce only occasionally.
function deferred<T>() {
let resolve!: (value: T) => void;
const promise = new Promise<T>(r => { resolve = r; });
return { promise, resolve };
}
test('rejects an expired coupon', async () => {
await assert.rejects(applyCoupon('EXPIRED'), { message: /expired/i });
});
test('the newest search wins even when it resolves first', async () => {
const first = deferred<string[]>();
const second = deferred<string[]>();
const pending = [first, second];
const controller = createSearchController(() => pending.shift()!.promise);
const a = controller.run('a');
const ab = controller.run('ab');
second.resolve(['ab result']); // newer request settles first
first.resolve(['a result']); // older request settles second
await Promise.all([a, ab]);
assert.deepEqual(controller.getState().results, ['ab result']);
});What the change buys you
| Behaviour | Before | After |
|---|---|---|
| Behaviour under test changes | Test stays green | Test fails |
| Where a failure is reported | After the test, attributed elsewhere | In the test that caused it |
| Test runtime | Sleeps proportional to the delays used | Immediate |
| Response ordering | Whatever the scheduler does today | Chosen explicitly by the test |
| Rerunning on a loaded CI machine | Intermittently red | Deterministic |
Variations worth trying
- Turn on the lint rules that catch this class of bug —
no-floating-promisesandrequire-awaitfind most unawaited assertions mechanically. - Assert on the error, not just that one occurred.
assert.rejects(p)passes for any rejection, including a TypeError from your own test setup. - Use fake timers for code that genuinely waits — retry backoff, debounce, lease expiry — so a ten-second policy takes no wall-clock time to test.
- Write the negative test: prove the stale response is ignored, not merely that the fresh one arrived. Those are different assertions and only one of them catches a regression.
TRAIN IT
Tests that protect
Write the tests first: pin the ordering, await every assertion, and make the suite fail for the right reason before you fix the code.
Open the workout