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(...)) }— addingasyncto the test function without actuallyawait-ing the promise inside it. Theasynckeyword alone doesn't make Jest wait for anything; it only matters if youawaitorreturnthe pending work. A missingawaitin an async test is one of the most common ways a real bug slips through a suite that looks perfectly reasonable at a glance.
setTimeout-based codeTesting 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.