Skip to main content
CodeOath
← All posts

Testing64 min total · 12 parts

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

Part 7 of 12 · ~4 min

Testing Asynchronous Code

A surprising share of suites that show all green are actually hiding real bugs somewhere in their async tests — because it's entirely possible for a test to finish, get marked as passed, and never once have actually run the assertion it exists to run.

Here is that failure on submitAssessment. It is green. It proves nothing:

// BROKEN — this test passes even if submitAssessment is completely broken.
test("submitAssessment returns the grader's report (broken)", () => {
  requestGrade.mockResolvedValue({ score: 80 });

  submitAssessment("iv_7c2a", "def solve(): pass", NOW).then((report) => {
    expect(report.score).toBe(80); // this callback may never run before the test ends
  });
  // the test function returns right here, synchronously, long before the
  // promise above settles — Jest marks it "passed" with zero assertions checked
});

As far as Jest is concerned, a test is over the moment its function returns — it doesn't wait around for every promise that function happened to kick off to actually settle. submitAssessment(...).then(...) hands back a promise immediately and moves on, and the one line that matters is sitting inside a callback that fires sometime later, well after Jest has already written down a pass.

Now change requestGrade to reject. The test still passes. Delete the body of submitAssessment entirely and have it return Promise.resolve(undefined). The test still passes — undefined.score would throw a TypeError, except that the callback containing it runs after Jest has stopped listening, so it surfaces as an unhandled rejection in the console at best and nothing at all at worst. This is the purest form of the problem: a test that cannot fail.

The remedy is simple in principle: give the test a way to actually wait for that async work before deciding pass or fail — either hand back the promise yourself, or, the far more common style today, use async/await:

// Fixed — returning the promise. Jest waits for whatever you return.
test("submitAssessment returns the grader's report (returned promise)", () => {
  requestGrade.mockResolvedValue({ score: 80 });
  return submitAssessment("iv_7c2a", "def solve(): pass", NOW).then((report) => {
    expect(report.score).toBe(80);
  });
});

// Fixed — async/await, the clearer modern style
test("submitAssessment returns the grader's report (async/await)", async () => {
  requestGrade.mockResolvedValue({ score: 80 });
  const report = await submitAssessment("iv_7c2a", "def solve(): pass", NOW);
  expect(report.score).toBe(80);
});

// Testing a rejection correctly — note the await in front of expect
test("submitAssessment rejects when the grader is unreachable", async () => {
  requestGrade.mockRejectedValue(new Error("ECONNREFUSED"));
  await expect(submitAssessment("iv_7c2a", "def solve(): pass", NOW))
    .rejects.toThrow("ECONNREFUSED");
});

Common mistake: marking a test function async and then never actually await-ing anything inside it — async () => { submitAssessment(...).then((r) => expect(...)) }. The async keyword by itself buys Jest nothing; it only starts waiting once you await or return the pending work yourself. The same trap hides inside expect(...).rejects and expect(...).resolves, both of which hand back promises of their own — expect(p).rejects.toThrow() with no await in front of it is the identical silently-passing test wearing a different outfit. A dropped await is one of the sneakiest ways a genuine bug gets past a suite that looks, on a quick read, completely reasonable.

Fake timers, for the editor's autosave

The browser editor saves the candidate's draft as they type — but not on every keystroke, because that would be a request per character. It debounces: wait until they stop typing for two seconds, then save once.

// lib/autosave.js
function autosave(save, delayMs) {
  let timeoutId;
  return (...args) => {
    clearTimeout(timeoutId);                               // cancel the previous plan
    timeoutId = setTimeout(() => save(...args), delayMs);  // make a new one
  };
}

Testing this the naive way leaves you two bad options: actually burn the real two seconds every time (slow, and it compounds — thirty tests like this is a full minute of CI doing nothing but waiting), or race it with a shorter fake delay and hope timing cooperates (flaky). Jest's fake timers sidestep the whole problem — they take over the clock the code under test relies on, so a test can jump forward in time instantly instead of actually waiting for it:

test("autosave saves once after the candidate stops typing, not per keystroke", () => {
  jest.useFakeTimers();
  const save = jest.fn();
  const scheduleSave = autosave(save, 2000);

  // Arrange + Act — three keystrokes in rapid succession
  scheduleSave("def sol");
  scheduleSave("def solve");
  scheduleSave("def solve():");

  expect(save).not.toHaveBeenCalled();       // nothing has fired — the timer has not elapsed

  jest.advanceTimersByTime(2000);            // fast-forward, no real waiting

  expect(save).toHaveBeenCalledTimes(1);     // exactly one save...
  expect(save).toHaveBeenCalledWith("def solve():"); // ...and it has the latest draft

  jest.useRealTimers();
});

Calling jest.advanceTimersByTime(ms) pushes the fake clock forward by that much and fires off anything that would have gone off along the way, all at once, with zero real time spent waiting. Reach for this anywhere timing is the thing under test — debounce, throttling, polling, backoff retries, the countdown on our own banner — whenever the goal is proving the timing logic works without your suite spending real wall-clock time sitting through the delays it's checking.

Hand the real clock back when you're done, either by calling jest.useRealTimers() at the end of the test or from an afterEach so you never forget. Skip that and the fake clock keeps running for every test that comes after it in the same file, and whatever breaks as a result is maddening to track down, because the test that goes red is rarely the one that actually caused the problem.