Testing & QA

Testing async code — promises, timers, and race conditions in tests

A test that doesn't actually wait for an async operation can pass or fail almost at random, independent of whether the code is correct — the fixes are specific, learnable patterns, not just 'add more waiting.'

Intermediate

3 min read

The test that finishes before the code under test does

// BROKEN — the test function returns before fetchUser's promise ever resolves
test("fetches and returns the user", () => {
  let user;
  fetchUser(1).then((result) => { user = result; });
  expect(user).toEqual({ id: 1, name: "Ada" }); // runs IMMEDIATELY — user is still undefined
});

fetchUser(1).then(...) schedules a callback for later, once the promise resolves — but the test function itself keeps running synchronously past that line immediately, reaching the expect before the promise has had any chance to resolve. This test doesn't fail because the code is wrong; it fails (or, depending on timing, occasionally happens to pass) because the test itself never actually waited for the asynchronous work to finish — a direct instance of the timing-based flakiness the previous lesson covered, isolated down to its simplest possible shape.

The fix: async/await in the test itself

test("fetches and returns the user", async () => {
  const user = await fetchUser(1); // the test itself now genuinely waits
  expect(user).toEqual({ id: 1, name: "Ada" });
});

Marking the test function async and await-ing the promise under test means the test genuinely pauses until the real work finishes, then asserts against the real, final result — deterministic and correct regardless of how long fetchUser actually takes. This is the single most common fix in this entire lesson: almost every testing framework (Jest, Vitest, pytest with pytest-asyncio, Go's testing package) supports async/await (or an equivalent) directly in test functions for exactly this reason.

Testing rejected promises: asserting the failure path, not just the happy path

test("throws when the user doesn't exist", async () => {
  await expect(fetchUser(999)).rejects.toThrow("User not found");
});

rejects unwraps a promise that's expected to reject, letting the assertion check the actual rejection reason — the async equivalent of a plain expect(() => fn()).toThrow() for synchronous code. Skipping this and only testing the resolved-successfully path is a common, real gap: a function's error handling is exactly as much a part of its contract as its happy path, and it's just as easy to get wrong (a swallowed error, a generic message that loses the real cause, a promise that never actually rejects when it should).

Fake timers: testing setTimeout/setInterval without actually waiting

test("retries a failed request after a 1-second delay", () => {
  jest.useFakeTimers();
  const onRetry = jest.fn();
 
  scheduleRetry(onRetry, 1000);
  jest.advanceTimersByTime(1000); // instantly "fast-forwards" 1 real second
 
  expect(onRetry).toHaveBeenCalled();
  jest.useRealTimers();
});

A test that genuinely waited a real second for every setTimeout-based behavior would make a suite with dozens of such tests take minutes instead of milliseconds — a real, practical problem at scale. Fake timers replace the real clock with a controllable, fast-forwardable one: advanceTimersByTime moves the fake clock forward instantly and fires any timers that would have fired in that window, letting a test verify timer-based behavior without any real waiting at all. This is the deliberate, correct alternative to real timers — not the same thing as ignoring timing altogether, which is exactly what causes the flakiness this lesson opened with.

Further reading

Check your understanding

A quick comprehension check — not tracked, not graded, just for you.

1. Why does `fetchUser(1).then(result => { user = result; }); expect(user)...` fail even when the code is correct?

2. What does `await expect(promise).rejects.toThrow(...)` let a test verify?

3. Why use fake timers instead of really waiting for a setTimeout-based behavior in a test?