CodeStaminaOpen preview

Test design · TypeScript · 10 min

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 this

The 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.

An assertion nobody waits for
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.

Awaited assertions and explicit interleaving
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

BehaviourBeforeAfter
Behaviour under test changesTest stays greenTest fails
Where a failure is reportedAfter the test, attributed elsewhereIn the test that caused it
Test runtimeSleeps proportional to the delays usedImmediate
Response orderingWhatever the scheduler does todayChosen explicitly by the test
Rerunning on a loaded CI machineIntermittently redDeterministic

Variations worth trying

  1. Turn on the lint rules that catch this class of bug — no-floating-promises and require-await find most unawaited assertions mechanically.
  2. Assert on the error, not just that one occurred. assert.rejects(p) passes for any rejection, including a TypeError from your own test setup.
  3. 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.
  4. 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

JOIN THE WAITLIST

Help shape what we build next.

Tell us where you want to start and what you want to practise. We’ll use your answers to understand demand and email you about relevant program availability.

What would you like to practise?

We store your email and answers for this waitlist. No campaign is being sent now. Contact mo@codestamina.com to ask us to remove your entry.