Testing async code
Learn to await promises, assert rejections, drive fake timers with microtasks, test events, and remove flaky async JavaScript tests.
- 01Make tests wait for real workReturn promises, use async test functions, and recognize false passes when an assertion runs late.
- 02Assert failures and time deliberatelyUse rejection assertions and fake clocks that flush promise callbacks between timer callbacks.
- 03Remove flakiness instead of hiding itReplace sleeps, shared state, real network, time zones, randomness, and races with deterministic seams.
Reliable tests wait for the right signal
Async code spreads work across promises, timers, events, streams, and the event loop. Testing it well is not about waiting longer. It is about giving the test runner the exact thing that proves the behavior is finished.
Testing async code means arranging an async operation, returning or awaiting the promise, event, timer, or stream completion that represents the work, and asserting the result before the test is allowed to pass.
A relay judge does not call the race over because a runner started moving. They wait for the baton to cross the line. In an async test, the baton is the returned promise, awaited event, stream completion, or fake-clock advance that proves the work finished.
- In real life: Runner hands off the baton
- In JavaScript: The test returns or awaits the promise
- In real life: Judge waits at the finish line
- In JavaScript: The runner waits for the async contract
- In real life: Dropping the baton ends the race early
- In JavaScript: A forgotten promise lets the test finish before the assertion
Where the analogy stops: A relay has one visible baton. JavaScript can have several queues at once: promise microtasks, timer tasks, event callbacks, stream events, and external APIs. The test must choose the signal that represents the behavior.
| Test body shape | Runner contract | Use it when |
|---|---|---|
| Return a promise | The runner waits for that promise to settle. | Good for promise chains and helper functions. |
Use an async test | The function automatically returns a promise. | The clearest modern default. |
Use done callbacks | The runner waits until done() or done(error) is called. | Legacy style; easy to forget error paths. |
| Schedule work without returning it | The runner sees undefined and can finish immediately. | The classic false-pass bug. |
This lesson connects to promises, async/await, microtasks, the event loop, timers, promise errors, the concurrent mocks and fakes lesson, and cancellation.
Awaiting in tests: the classic false pass
STEP THROUGHThe classic bug is small: a test starts async work, but does not return or await the promise that contains the assertion. The runner sees undefined, marks the test done, and the assertion happens later. Sometimes it becomes an unhandled rejection; sometimes a catch logs it; either way, the test result was not trustworthy.
Step through the same bug twice. The code under test is identical; only the returned promise changes whether the assertion counts.
script
const log = (line) => { output.push(line); console.log(line); }; function assertEqual(actual, expected) { if (actual !== expected) throw new Error(`expected ${expected}, got ${actual}`);}async function test(name, body) { try { const result = body(); if (result && typeof result.then === "function") await result; log(`PASS ${name}`); } catch (error) { log(`FAIL ${name}: ${error.message}`); }}const fetchStatus = () => Promise.resolve("saved"); test("forgot to await", () => { fetchStatus() .then((status) => assertEqual(status, "draft")) .catch((error) => log(`LATE ${error.message}`));});setTimeout(() => log(`SUMMARY ${output.join(" | ")}`), 0);The tiny harness is intentionally simple: it calls the test body, checks whether the returned value looks like a promise, awaits it when present, then records pass or fail. Real runners such as node:test, Vitest, and Jest do the same kind of contract check around your test function.
const output = [];const log = (line) => { output.push(line); console.log(line); }; function assertEqual(actual, expected) { if (actual !== expected) throw new Error(`expected ${expected}, got ${actual}`);}async function test(name, body) { try { const result = body(); if (result && typeof result.then === "function") await result; log(`PASS ${name}`); } catch (error) { log(`FAIL ${name}: ${error.message}`); }}const fetchStatus = () => Promise.resolve("saved"); test("forgot to await", () => { fetchStatus() .then((status) => assertEqual(status, "draft")) .catch((error) => log(`LATE ${error.message}`));});setTimeout(() => log(`SUMMARY ${output.join(" | ")}`), 0);The harness says pass, then the late assertion reports the bug after the test result.
Prefer async test functions and await the behavior you assert. Returning a promise chain is also fine. Use done callbacks only in older suites, and call done(error) on every failure path.
Assert resolved values directly: assert.equal(await loadName(), "Ada"), await expect(loadName()).resolves.toBe("Ada"), or the equivalent in your runner. The important part is that the assertion is inside the promise the runner is waiting for.
Testing rejections without swallowing the failure
REAL NODE OUTPUTA rejected promise is not a synchronous throw. Use rejection assertions so the test fails when the promise unexpectedly resolves, and so the expected error message or type is checked.
| Runner | Pattern | Why it is safer |
|---|---|---|
Node assert.rejects | await assert.rejects(promise, /message/) | Fails if the promise resolves or rejects with the wrong error. |
| Vitest/Jest | await expect(promise).rejects.toThrow(/message/) | Remember the leading await; otherwise the matcher promise may be ignored. |
Manual try/catch | Only use with a clear failure after the awaited call. | A loose catch can catch your own assert.fail() and create a false pass. |
assert.rejectsJavaScriptimport test from "node:test";import assert from "node:assert/strict"; async function saveInvoice() { throw new Error("offline");} test("saveInvoice rejects when storage is offline", async () => { await assert.rejects(saveInvoice(), /offline/);});TAP version 13
# Subtest: saveInvoice rejects when storage is offline
ok 1 - saveInvoice rejects when storage is offline
---
duration_ms: <normalized>
type: 'test'
...
1..1
# tests 1
# suites 0
# pass 1
# fail 0
# cancelled 0
# skipped 0
# todo 0
# duration_ms <normalized>// Vitestawait expect(saveInvoice()).rejects.toThrow(/offline/); // Jestawait expect(saveInvoice()).rejects.toThrow(/offline/);async function save(mode) { if (mode === "fail") throw new Error("offline"); return "ok";}async function expectRejects(promise, pattern) { try { await promise; return "FAIL resolved instead of rejected"; } catch (error) { return pattern.test(error.message) ? `PASS rejected with ${error.message}` : `FAIL wrong error: ${error.message}`; }} (async () => { console.log(await expectRejects(save("fail"), /offline/)); console.log(await expectRejects(save("ok"), /offline/));})();The first promise rejects with the expected message. The second resolves, so a proper rejection assertion must report failure instead of passing silently.
assert.rejects, expect(...).rejects, and similar APIs are protecting for you.Manual try/catch can work, but it is easy to catch the wrong thing. The common pitfall is placing assert.fail() or throw new Error("expected rejection") inside the try, then catching that failure and treating it as the expected rejection.
try/catch pitfallasync function shouldReject() { return "ok";} (async () => { try { await shouldReject(); throw new Error("expected rejection"); } catch (error) { console.log("caught " + error.message); console.log("A loose catch would treat this as a pass."); }})();Modern runners fail runs when a promise rejects outside the test that created it. Treat that as useful feedback: some async work escaped the test's contract. Return the promise, await it, or assert the rejection directly.
Fake timers with promises: move both queues
MICROTASKSTimer callbacks are tasks. Promise callbacks are microtasks. Advancing a fake clock can run a timer callback, but a Promise.then scheduled inside that callback still needs a microtask turn. If you assert too early, the code looks broken even though the promise is merely waiting in the next queue.
const events = [];class TinyClock { constructor() { this.now = 0; this.jobs = []; } setTimeout(callback, ms) { this.jobs.push({ at: this.now + ms, callback }); } advance(ms) { const end = this.now + ms; for (const job of this.jobs.filter((job) => job.at <= end)) { this.now = job.at; job.callback(); } this.now = end; }} const clock = new TinyClock();clock.setTimeout(() => { events.push("timer"); Promise.resolve().then(() => events.push("promise"));}, 10);clock.advance(10);console.log(events.join(" -> "));Promise.resolve().then(() => console.log(events.join(" -> ")));The fake timer callback runs first. The Promise.then scheduled inside it appears only after JavaScript gets a microtask turn.
A reliable fake clock for promise-heavy code must run due timers and then give promise callbacks a microtask turn before continuing.
script
constructor() { this.now = 0; this.jobs = []; } delay(ms) { return new Promise((resolve) => { this.jobs.push({ at: this.now + ms, resolve }); }); } async advanceAsync(ms) { const target = this.now + ms; while (true) { this.jobs.sort((a, b) => a.at - b.at); const job = this.jobs[0]; if (!job || job.at > target) break; this.jobs.shift(); this.now = job.at; job.resolve(); await Promise.resolve(); } this.now = target; await Promise.resolve(); }}async function retryWithBackoff(work, clock) { for (const wait of [100, 200]) { try { return await work(); } catch { await clock.delay(wait); } } return work();} (async () => { const events = []; const clock = new FakeClock(); let attempts = 0; const work = async () => { attempts += 1; events.push(`try ${attempts} at ${clock.now}`); if (attempts < 3) throw new Error("offline"); return "ok"; }; const result = retryWithBackoff(work, clock); await Promise.resolve(); await clock.advanceAsync(100); await clock.advanceAsync(200); events.push(await result); console.log(events.join(" -> "));})();The lesson fake clock is a real function, not a drawing. delay(ms) returns a promise. advanceAsync(ms) finds due delays, resolves them, and awaits a microtask after each timer callback so promise continuations can schedule the next delay.
advanceAsyncclass FakeClock { constructor() { this.now = 0; this.jobs = []; } delay(ms) { return new Promise((resolve) => { this.jobs.push({ at: this.now + ms, resolve }); }); } async advanceAsync(ms) { const target = this.now + ms; while (true) { this.jobs.sort((a, b) => a.at - b.at); const job = this.jobs[0]; if (!job || job.at > target) break; this.jobs.shift(); this.now = job.at; job.resolve(); await Promise.resolve(); } this.now = target; await Promise.resolve(); }}async function retryWithBackoff(work, clock) { for (const wait of [100, 200]) { try { return await work(); } catch { await clock.delay(wait); } } return work();} (async () => { const events = []; const clock = new FakeClock(); let attempts = 0; const work = async () => { attempts += 1; events.push(`try ${attempts} at ${clock.now}`); if (attempts < 3) throw new Error("offline"); return "ok"; }; const result = retryWithBackoff(work, clock); await Promise.resolve(); await clock.advanceAsync(100); await clock.advanceAsync(200); events.push(await result); console.log(events.join(" -> "));})();Running...The clock jumps from 0 to 100 to 300, and the promise chain has a chance to continue after each due timer.
| Environment | API names | Important detail |
|---|---|---|
Node 22 node:test | mock.timers.enable({ apis: ["setTimeout", "Date"], now }), then tick() or runAll(). | Date is mocked when Date is included, or when you enable the default API set. The API is experimental. |
| Vitest | vi.useFakeTimers() and await vi.advanceTimersByTimeAsync(ms). | The async helper advances timers and lets promise callbacks settle. |
| Jest | jest.useFakeTimers() and await jest.advanceTimersByTimeAsync(ms). | Use the async helper for promise-heavy timer code. |
| Hand-rolled fake clock | Inject a clock.delay(ms) seam and test it with advanceAsync(ms). | Best when you own the production code and want no global timer mocking. |
node:test mock timersJavaScriptimport { mock, test } from "node:test";import assert from "node:assert/strict"; test("timer callback runs on the fake clock", () => { mock.timers.enable({ apis: ["setTimeout", "Date"], now: new Date("2026-01-01T00:00:00Z"), }); const seen = []; setTimeout(() => seen.push(Date.now()), 50); mock.timers.tick(50); assert.deepEqual(seen, [1767225600050]); mock.timers.reset();});Node 22.23.1 exposes mock.timers.enable, mock.timers.tick, mock.timers.runAll, and mock.timers.reset. Passing { apis: ["setTimeout", "Date"], now } mocks both timers and Date; if you list only setTimeout, Date.now() stays real.
// Vitestvi.useFakeTimers();const promise = retryWithBackoff(work);await vi.advanceTimersByTimeAsync(300);await expect(promise).resolves.toBe("ok"); // Jestjest.useFakeTimers();const result = retryWithBackoff(work);await jest.advanceTimersByTimeAsync(300);await expect(result).resolves.toBe("ok");Testing events and streams
WAIT FIRSTEvents are easy to miss if you attach the listener after triggering the behavior. Start the wait first, trigger the action second, and give the wait a deadline so a missing event fails quickly.
const target = new EventTarget();function waitForEvent(target, type, { signal } = {}) { return new Promise((resolve, reject) => { const cleanup = () => signal?.removeEventListener("abort", onAbort); const onEvent = (event) => { cleanup(); resolve(event); }; const onAbort = () => { target.removeEventListener(type, onEvent); reject(signal.reason ?? new Error("aborted")); }; target.addEventListener(type, onEvent, { once: true }); signal?.addEventListener("abort", onAbort, { once: true }); });} const saved = waitForEvent(target, "saved", { signal: AbortSignal.timeout(50),});target.dispatchEvent(new CustomEvent("saved", { detail: { id: 7 } }));saved.then((event) => console.log(event.detail.id));Waiting...The test starts waiting before dispatching the event. { once: true } cleans up after the first event, and AbortSignal.timeout gives the wait a deadline.
events.once.Node's events.once gives EventEmitter and stream tests the same promise shape. Streams often finish with end, finish, close, or error; choose the event that represents the behavior your test promises.
events.once with a timeout signalJavaScriptimport { EventEmitter, once } from "node:events"; const emitter = new EventEmitter();const saved = once(emitter, "saved", { signal: AbortSignal.timeout(50),}); emitter.emit("saved", { id: 7 });const [payload] = await saved;console.log(payload.id);import test from "node:test"; test("polls until ready", { timeout: 100 }, async () => { await waitForReadyState();});AbortSignal.timeout(50) is useful in examples and wrappers because it cancels the wait itself. Runner timeouts, such as test(name, { timeout: 100 }, async () => ...), protect the whole test from hanging forever.
Fix flaky tests by removing guesses
SORT ITFlaky async tests usually have one of a few causes: real timers or sleeps, shared state between tests, order dependence, real network calls, time zones and dates, randomness, or race conditions. The fix is to make the signal deterministic before you reach for retries.
| Cause | Smell | First repair |
|---|---|---|
| Real sleep | await sleep(1000) | Use fake time, a promise for the exact event, or a polling helper with a short timeout. |
| Shared state | One test leaves data for the next test. | Create fresh fixtures in each test and clean up after external resources. |
| Order dependence | The suite passes alone but fails after another file. | Avoid global mutation and run focused tests in random order while fixing. |
| Network or clock | CI is slower, offline, in another time zone, or on another date. | Inject clients, freeze dates, set time zones, and use recorded responses. |
| Race condition | The assertion checks before the UI, stream, or callback is done. | Await the user-visible result, the event, or the stream completion, not an implementation guess. |
`await sleep(500)` after clicking Save- A retry test waits through 100ms, 200ms, then 400ms
- A module-level cache survives between tests
- The expected label depends on today's date
- The test calls the real payment sandbox
- The assertion expects a random ID
Sort each flaky symptom by the deterministic repair you should try before retries.
Retries can reduce noise for genuinely external instability, but they also hide races and make failures slower. First await the right thing, fake or inject time, isolate state, and control network, randomness, dates, and time zones.
Common misconceptions
- “The test is marked
async, so everything inside counts.” Only awaited work or a returned promise belongs to the runner's contract. - “A sleep proves the UI or stream is done.” A sleep proves only that time passed. Wait for the visible result, event, or completion signal.
- “Fake timers run promise callbacks too.” Timer callbacks and promise callbacks are separate queues. Use async timer helpers or flush microtasks.
- “Any caught error means the rejection happened.” A broad catch can catch your own assertion failure. Use rejection-specific assertions.
- “Retries fix flakes.” Retries hide symptoms. Deterministic seams fix the test.
| Tool | Good use | Common mistake |
|---|---|---|
async test | Return a promise the runner waits for. | Start async work but forget to await it. |
| Fake timers | Drive timeout and interval behavior instantly. | Assert before microtasks scheduled by timer callbacks flush. |
| Timeout | Fail a stuck wait quickly. | Use it as a sleep that guesses success. |
| Retry | Contain rare external instability after deterministic fixes. | Hide a race or shared-state leak. |
Practice exercises
5 EXERCISESPredict the first test-result text that appears before the late assertion updates the log.
const output = [];
async function test(name, body) {
const result = body();
if (result && typeof result.then === "function") await result;
output.push("PASS " + name);
}
const later = () => Promise.resolve("ready");
test("forgot", () => {
later().then((value) => output.push("ASSERT " + value));
});
setTimeout(() => console.log(output.join(" | ")), 0);The first visible test result is PASS forgot. The later assertion happens after the runner has already accepted the test.
In a Node test, what helper should check that loadUser() rejects with missing user?
await assert.rejects(loadUser(), /missing user/);assert.rejects waits for the promise, fails if it resolves, and checks the rejection message.
Use the backoff order from the lesson fake clock. What value does the retry promise finally resolve to?
// Expected event order from the lesson fake clock:
// try 1 at 0 -> try 2 at 100 -> try 3 at 300 -> okAfter two fake delays, the third attempt returns ok, so the final resolved value is ok.
Predict the value logged by the event promise.
const target = new EventTarget();
const promise = new Promise((resolve) => {
target.addEventListener("done", (event) => resolve(event.detail), { once: true });
});
target.dispatchEvent(new CustomEvent("done", { detail: "saved" }));
promise.then((value) => console.log(value));The event carries detail: "saved", and the promise resolves with that detail before logging it.
A save-button test uses await sleep(500) and sometimes fails in CI. Should the first fix be to add retries or remove the race by awaiting the right thing?
await screen.findByText('Saved');Remove the race by awaiting the user-visible result. Retries are only a last resort after deterministic waits are in place.
Check your understanding
7 QUESTIONSQuestion 1 of 7What is the safest default shape for a modern promise-based test?
Choose an answer to see the explanation.
Question 2 of 7What does this tiny harness print first?
Read the code, then predictconst output = []; async function test(name, body) { const result = body(); if (result && typeof result.then === "function") await result; output.push("PASS " + name); } const later = () => Promise.resolve("ready"); test("forgot", () => { later().then((value) => output.push("ASSERT " + value)); }); setTimeout(() => console.log(output[0]), 0);Choose an answer to see the explanation.
Question 3 of 7Which assertion correctly verifies a promise rejection in Node?
Read the code, then predictJavaScriptasync function saveInvoice() { throw new Error("offline"); }Choose an answer to see the explanation.
Question 4 of 7What does this timer-and-promise example print first?
Read the code, then predictconst events = []; function runTimerCallback() { events.push("timer"); Promise.resolve().then(() => events.push("promise")); } runTimerCallback(); console.log(events.join(" -> ")); Promise.resolve().then(() => console.log(events.join(" -> ")));Choose an answer to see the explanation.
Question 5 of 7Why does
advanceTimersByTimeAsyncmatter in Vitest and Jest?Choose an answer to see the explanation.
Question 6 of 7What is the best way to test a one-time event?
Choose an answer to see the explanation.
Question 7 of 7A test passes locally but fails in CI around midnight. What is the first fix?
Choose an answer to see the explanation.
Key takeaways
- Async tests pass only after the runner waits for the promise, event, timer, or stream that contains the assertion.
- Use
asynctests, returned promises, or carefully wired legacydonecallbacks; never start async work and returnundefined. - Use
assert.rejectsorexpect(...).rejectsfor promise errors, and treat unhandled rejections as escaped work. - Fake timers and promises need microtask flushing; prefer async timer helpers or an injected clock with
advanceAsync. - Fix flaky tests by removing guesses: fake time, isolate state, control external inputs, and await the right signal.
Remember the one-liner.
A reliable async test waits for behavior, not for a hoped-for amount of time.
Up next: Testing the DOM & end to end.