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
asyncand then never actuallyawait-ing anything inside it —async () => { submitAssessment(...).then((r) => expect(...)) }. Theasynckeyword by itself buys Jest nothing; it only starts waiting once youawaitorreturnthe pending work yourself. The same trap hides insideexpect(...).rejectsandexpect(...).resolves, both of which hand back promises of their own —expect(p).rejects.toThrow()with noawaitin front of it is the identical silently-passing test wearing a different outfit. A droppedawaitis 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.