CodeOath
← All posts
Testing36 min total · 12 parts

Testing Fundamentals: Unit, Integration, and E2E Tests Done Right

Part 7 of 12 · ~3 min

Testing Asynchronous Code

Async code is where a surprising number of "green" test suites are hiding real bugs, because a test can finish, report success, and never actually have run the assertion it was supposed to check.

// BROKEN — this test always passes, even if fetchUser is completely broken.
test("fetchUser returns a user (broken)", () => {
  fetchUser(1).then((user) => {
    expect(user.name).toBe("Ana"); // this callback may never even run before the test ends
  });
  // the test function returns immediately here, synchronously,
  // long before the Promise above has settled — Jest marks it "passed" with zero assertions checked
});

Jest considers a test finished the instant its function returns (or the callback it's given fires), not when every promise it kicked off eventually settles. Since fetchUser(1).then(...) returns immediately and the actual assertion sits inside a callback that fires later, the test function returns before that callback — and its expect — ever runs. If fetchUser were broken and rejected instead of resolving, this test would still print "passed," because the failing assertion inside the .then() never got a chance to execute at all.

The fix is to make the test wait for the async work — either by returning the promise, or with async/await:

// Fixed — returning the promise
test("fetchUser returns a user (returns the promise)", () => {
  return fetchUser(1).then((user) => {
    expect(user.name).toBe("Ana");
  });
});

// Fixed — async/await, the clearer modern style
test("fetchUser returns a user (async/await)", async () => {
  const user = await fetchUser(1);
  expect(user.name).toBe("Ana");
});

// Testing a rejection correctly
test("fetchUser rejects for an unknown id", async () => {
  await expect(fetchUser(-1)).rejects.toThrow("not found");
});

Common mistake: writing async () => { fetchUser(1).then(user => expect(...)) } — adding async to the test function without actually await-ing the promise inside it. The async keyword alone doesn't make Jest wait for anything; it only matters if you await or return the pending work. A missing await in an async test is one of the most common ways a real bug slips through a suite that looks perfectly reasonable at a glance.

Fake timers for debounce and setTimeout-based code

Testing a debounced function or anything built on setTimeout the naive way means either waiting out the real delay (slow, and it adds up fast across a whole suite) or racing it (flaky). Jest's fake timers solve this by letting a test fast-forward time instantly, without ever actually waiting:

function debounce(fn, delay) {
  let timeoutId;
  return (...args) => {
    clearTimeout(timeoutId);
    timeoutId = setTimeout(() => fn(...args), delay);
  };
}

test("debounce only calls fn once after the delay, not on every call", () => {
  jest.useFakeTimers();
  const fn = jest.fn();
  const debounced = debounce(fn, 300);

  debounced();
  debounced();
  debounced(); // three rapid calls
  expect(fn).not.toHaveBeenCalled(); // none of them have fired yet — timer hasn't elapsed

  jest.advanceTimersByTime(300); // fast-forward, no real waiting
  expect(fn).toHaveBeenCalledTimes(1); // only the last scheduled call actually fired

  jest.useRealTimers(); // restore real timers for any tests after this one
});

jest.advanceTimersByTime(ms) moves the fake clock forward and runs any timers that would have fired in that window, instantly, with no real delay elapsing. This is exactly the tool for anything time-based — debounce, throttle, polling, retry-with-backoff — where you want to verify the timing logic is correct without your test suite actually taking as long as the real delays it's covering. Always call jest.useRealTimers() afterward (or in an afterEach), since fake timers left active leak into later tests in the same file and produce confusing, hard-to-trace failures.