Skip to main content

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 done with an async test 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 with await instead.

Fake Timers

Real time is the enemy of fast tests. Suppose orders auto-cancel if payment doesn't arrive within 30 minutes:

src/orderTimeout.js
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:

src/orderTimeout.test.js
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

FunctionWhat 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:

src/paymentRetry.js
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 };
src/paymentRetry.test.js
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.
  • advanceTimersByTimeAsync and runAllTimersAsync (Jest 29.5+) yield to the microtask queue between firings, so the await 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

  • async test + await every promise; never fire-and-forget inside a test.
  • Failure paths: await expect(p).rejects.toThrow(...), or expect.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.setSystemTime with a fixed date.
  • Always restore real timers in afterEach — leaked fake timers make unrelated tests hang.