Async Testing
The Async Trap
The most dangerous async test is one that passes when the code is broken. This test always passes, even if chargeCard rejects:
// BROKEN — the test finishes before the promise settles
test("charges the card", () => {
chargeCard(order).then((receipt) => {
expect(receipt.status).toBe("paid");
});
});
Jest sees a synchronous function that returns undefined, declares victory, and moves on. The assertion runs later — or never — and its failure is swallowed. Every pattern in this page exists to prevent this one bug.
The fix is always the same rule: Jest must be handed the promise. Either return it or await it.
Promises: Return or Await
// Option 1: return the promise
test("charges the card", () => {
return chargeCard(order).then((receipt) => {
expect(receipt.status).toBe("paid");
});
});
// Option 2: async/await — what I use everywhere
test("charges the card", async () => {
const receipt = await chargeCard(order);
expect(receipt.status).toBe("paid");
});
Prefer async/await. It reads top-to-bottom, stack traces are cleaner, and there's no chance of forgetting a return deep inside a .then chain.
Testing Rejections
Testing the failure path has its own trap. This looks reasonable but silently passes when chargeCard succeeds:
// BROKEN — if chargeCard resolves, the catch block never runs
// and the test passes with zero assertions
test("rejects an expired card", async () => {
try {
await chargeCard(orderWithExpiredCard);
} catch (err) {
expect(err.message).toBe("Card expired");
}
});
Two correct ways:
// Option 1: .rejects — the cleanest
test("rejects an expired card", async () => {
await expect(chargeCard(orderWithExpiredCard)).rejects.toThrow("Card expired");
});
// Option 2: expect.assertions guards the try/catch version
test("rejects an expired card", async () => {
expect.assertions(1);
try {
await chargeCard(orderWithExpiredCard);
} catch (err) {
expect(err.message).toBe("Card expired");
}
});
expect.assertions(1) makes the test fail unless exactly one assertion ran — so a resolving promise can no longer sneak past. The mirror matcher exists for success: await expect(promise).resolves.toEqual(value).
Callbacks and done
Some older APIs take callbacks instead of returning promises. For those, accept the done parameter — Jest waits until it's called:
test("reads legacy config via callback", (done) => {
loadConfig("payments", (err, config) => {
try {
expect(err).toBeNull();
expect(config.provider).toBe("stripe");
done();
} catch (error) {
done(error);
}
});
});
The try/catch around the assertions matters: a throwing expect inside a callback would otherwise surface as a confusing timeout instead of a failed assertion. Two rules:
- Never mix
donewith anasynctest function — Jest 27+ fails the test immediately with an error, because it can't tell which signal to trust. - When possible, wrap the callback API in a promise (
util.promisify) and test it withawaitinstead.
Fake Timers
Real time is the enemy of fast tests. Suppose orders auto-cancel if payment doesn't arrive within 30 minutes:
function scheduleAutoCancel(order, cancelFn) {
return setTimeout(() => {
cancelFn(order.id, "payment window expired");
}, 30 * 60 * 1000);
}
module.exports = { scheduleAutoCancel };
You can't wait 30 minutes. Fake timers let the test own the clock:
const { scheduleAutoCancel } = require("./orderTimeout");
beforeEach(() => {
jest.useFakeTimers();
});
afterEach(() => {
jest.useRealTimers();
});
test("cancels the order after 30 minutes of no payment", () => {
const cancelFn = jest.fn();
const order = { id: "ord_781", customer: "Zakariya" };
scheduleAutoCancel(order, cancelFn);
jest.advanceTimersByTime(29 * 60 * 1000);
expect(cancelFn).not.toHaveBeenCalled();
jest.advanceTimersByTime(60 * 1000);
expect(cancelFn).toHaveBeenCalledWith("ord_781", "payment window expired");
});
Note the negative assertion at 29 minutes — it proves the timeout isn't firing early, not just that it fires eventually.
The Timer Control Functions
| Function | What it does |
|---|---|
jest.advanceTimersByTime(ms) | Fast-forward the clock by ms, firing everything due in that window |
jest.runAllTimers() | Fire every pending timer, including ones scheduled by other timers |
jest.runOnlyPendingTimers() | Fire currently pending timers, but not ones they schedule |
jest.setSystemTime(date) | Pin Date.now and new Date to a fixed moment |
runOnlyPendingTimers is the escape hatch for self-rescheduling code. A polling loop that does setTimeout(poll, 5000) inside poll will make runAllTimers loop forever; runOnlyPendingTimers fires exactly one round.
Fake Timers and Async Code Together
Here's where most people get stuck. When the code under test awaits between timer firings, advanceTimersByTime isn't enough — the fired callback returns a promise that needs to settle before the next timer is scheduled. Use the async variants:
const wait = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
async function chargeWithRetry(gateway, payment, maxAttempts = 3) {
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
try {
return await gateway.charge(payment);
} catch (err) {
if (attempt === maxAttempts) throw err;
await wait(1000 * 2 ** attempt);
}
}
}
module.exports = { chargeWithRetry };
const { chargeWithRetry } = require("./paymentRetry");
beforeEach(() => jest.useFakeTimers());
afterEach(() => jest.useRealTimers());
test("retries twice with backoff, then succeeds", async () => {
const gateway = {
charge: jest
.fn()
.mockRejectedValueOnce(new Error("gateway timeout"))
.mockRejectedValueOnce(new Error("gateway timeout"))
.mockResolvedValue({ status: "paid", customer: "Ayesha Ashiq" }),
};
const resultPromise = chargeWithRetry(gateway, { amount: 4999 });
await jest.advanceTimersByTimeAsync(2000);
await jest.advanceTimersByTimeAsync(4000);
await expect(resultPromise).resolves.toEqual({
status: "paid",
customer: "Ayesha Ashiq",
});
expect(gateway.charge).toHaveBeenCalledTimes(3);
});
test("gives up after the final attempt", async () => {
const gateway = {
charge: jest.fn().mockRejectedValue(new Error("card declined")),
};
const resultPromise = chargeWithRetry(gateway, { amount: 4999 });
resultPromise.catch(() => {}); // prevent unhandled-rejection noise while we advance time
await jest.runAllTimersAsync();
await expect(resultPromise).rejects.toThrow("card declined");
expect(gateway.charge).toHaveBeenCalledTimes(3);
});
Two details worth stealing:
- Start the async operation first, don't await it yet, advance the clock, then await the result. Awaiting first would deadlock — the promise can't settle until timers fire, and timers can't fire while you're awaiting.
advanceTimersByTimeAsyncandrunAllTimersAsync(Jest 29.5+) yield to the microtask queue between firings, so theawait wait(...)inside the retry loop actually resumes. The sync variants would advance the clock without letting the awaited code continue.
Pinning the Clock
Anything that reads the current time — invoice dates, token expiry, "posted 3 hours ago" labels — needs a pinned clock to be testable:
test("marks the subscription as expired one day after the end date", () => {
jest.useFakeTimers();
jest.setSystemTime(new Date("2026-07-02T00:00:00Z"));
const subscription = { owner: "Ibrahim", endsAt: new Date("2026-07-01T00:00:00Z") };
expect(isExpired(subscription)).toBe(true);
jest.useRealTimers();
});
Timeouts
Jest fails any test still running after 5 seconds. For a genuinely slow integration test, raise it per test rather than globally:
test("processes a large import file", async () => {
await importOrders("./fixtures/10k-orders.csv");
}, 15000);
If unit tests are hitting the timeout, that's rarely a timeout problem — it's usually a promise that never settles (often a forgotten mock: jest.fn() returns undefined, and await undefined is fine, but an un-mocked network call hangs forever).
Checklist
asynctest +awaitevery promise; never fire-and-forget inside a test.- Failure paths:
await expect(p).rejects.toThrow(...), orexpect.assertions(n)with try/catch. - Time-based code: fake timers, advance explicitly, assert the "not yet" case too.
- Code that awaits between timers: use
advanceTimersByTimeAsync/runAllTimersAsync. - Current-time reads:
jest.setSystemTimewith a fixed date. - Always restore real timers in
afterEach— leaked fake timers make unrelated tests hang.