Asynchronous tests

Write asynchronous tests that actually assert something, and prove a rejection properly.

08of 16
≈ 12Min read
5Parts

The test that passes because it never ran

The test function returned immediately. The assertion ran later, on an already-finished test, and if it threw, the failure was reported against some other test — or nowhere at all. Await it, or return the promise. Every runner treats a returned promise as "wait for this".

the single most common bug in test code
it("saves the order", () => {
  save(order).then((saved) => {
    expect(saved.status).toBe("paid");     // this runs AFTER the test finished
  });
});                                        // …so the test passed, whatever the answer was
both of these are correct
it("saves the order", async () => {
  const saved = await save(order);

  expect(saved.status).toBe("paid");
});

it("saves the order", () => save(order).then((saved) => {
  expect(saved.status).toBe("paid");
}));

Testing a rejection

The rule: a test about failure must fail when the failure does not happen. A bare try/catch with the assertion inside is the version everybody writes once, and it is green on the day the bug appears.

and the version that passes when nothing throws
// wrong: if load() resolves, the test passes silently
it("fails for a missing order", async () => {
  try {
    await load(999);
  } catch (error) {
    expect(error.message).toContain("not found");
  }
});

// right: assert that it rejected, and why
it("fails for a missing order", async () => {
  await expect(load(999)).toReject("not found");
});

// or, without a matcher for it
it("fails for a missing order", async () => {
  let threw = null;
  try { await load(999); } catch (error) { threw = error; }

  expect(threw).toBeTruthy();
  expect(threw.message).toContain("not found");
});

Waiting for something to become true

A fixed sleep is a bet about the speed of a machine you have not met. Polling until a condition holds finishes as soon as it is true — usually in a few milliseconds — and fails with a clear timeout rather than an assertion about the wrong moment.

poll, do not sleep
// flaky: too short on a loaded machine, and slow on every other run
await new Promise((r) => setTimeout(r, 500));
expect(list.children).toHaveLength(3);

// stable: check often, give up eventually
await waitFor(() => list.children.length === 3, { timeout: 1000 });
Better still: do not wait at all

If the thing you are waiting for is your own timer, use fake timers from the last lesson. Waiting is for things you genuinely do not control — a real network, a real browser.

Parallel, and the order of failures

Promise.all is faster, and hides all but the first failure
const [a, b] = await Promise.all([load(1), load(2)]);              // first rejection wins

const results = await Promise.allSettled([load(1), load(2)]);      // every outcome
expect(results.map((r) => r.status)).toEqual(["fulfilled", "rejected"]);

Timeouts

Every runner kills a test that takes too long — five seconds by default in most. A test that hits that limit is usually not slow: it is a test that awaits a promise which never settles, because the thing that was supposed to resolve it never happened. Read a timeout as "this test is waiting for something that will not arrive", not as "raise the limit".

Practice