cf.completefrontendCode editorOpen lab
THE JAVASCRIPT FIELD GUIDE

Testing async code

Learn to await promises, assert rejections, drive fake timers with microtasks, test events, and remove flaky async JavaScript tests.

By the end, you can
  • 01
    Make tests wait for real workReturn promises, use async test functions, and recognize false passes when an assertion runs late.
  • 02
    Assert failures and time deliberatelyUse rejection assertions and fake clocks that flush promise callbacks between timer callbacks.
  • 03
    Remove 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.

Definition

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.

Real-life analogyA relay race with a real baton

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.

How runners know whether to wait
Test body shapeRunner contractUse it when
Return a promiseThe runner waits for that promise to settle.Good for promise chains and helper functions.
Use an async testThe function automatically returns a promise.The clearest modern default.
Use done callbacksThe runner waits until done() or done(error) is called.Legacy style; easy to forget error paths.
Schedule work without returning itThe 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 THROUGH

The 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.

Awaiting lab: does the assertion count?
Step 0 of 6Ready
Your turn: follow the blue line

Step through the same bug twice. The code under test is identical; only the returned promise changes whether the assertion counts.

Running in
  1. script
Next: line 1
Click the blue line to take the next stepPop out in the code editor (opens in a new tab)JavaScript
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);
CallStoreChangeResultRun = next line. Ran = already executed.
Recent returnsNothing yet. Start with the blue line.
Choose the test body shape

The async-aware harness only waits when the body returns a promise. Changing the mode starts a fresh replay.

A guided replay recorded from real JavaScript calls, not an engine debugger. Step follows executed statements; Back reviews a snapshot. Reset starts a fresh run.

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.

Change one word: false pass, real failure, real pass
Tiny async test harnessPop out in the code editor (opens in a new tab)JavaScript
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);
Outputforgot
Try it yourself

The harness says pass, then the late assertion reports the bug after the test result.

The source and output come from the lesson's tiny async-aware harness. No real sleeps are used.
Modern shapes to prefer

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 OUTPUT

A 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.

Rejection assertion patterns
RunnerPatternWhy it is safer
Node assert.rejectsawait assert.rejects(promise, /message/)Fails if the promise resolves or rejects with the wrong error.
Vitest/Jestawait expect(promise).rejects.toThrow(/message/)Remember the leading await; otherwise the matcher promise may be ignored.
Manual try/catchOnly use with a clear failure after the awaited call.A loose catch can catch your own assert.fail() and create a false pass.
Node: assert.rejectsJavaScript
import 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/);});
node:test TAP output from that filetext
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>
Vitest and Jest rejection matchersJavaScript
// Vitestawait expect(saveInvoice()).rejects.toThrow(/offline/); // Jestawait expect(saveInvoice()).rejects.toThrow(/offline/);
A rejection assertion must fail when the promise resolves
Rejects helper modelPop out in the code editor (opens in a new tab)JavaScript
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/));})();
Outputrejects
Try it yourself
promise outcomes

The first promise rejects with the expected message. The second resolves, so a proper rejection assertion must report failure instead of passing silently.

The helper mirrors what 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.

The broad try/catch pitfallPop out in the code editor (opens in a new tab)JavaScript
async 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.");  }})();
Unhandled rejections are failures

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

MICROTASKS

Timer 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.

Fake time does not automatically flush promise callbacks
Timer callback schedules a promise callbackPop out in the code editor (opens in a new tab)JavaScript
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(" -> ")));
Observed outputreal microtasks
Try it yourself
timer then microtask

The fake timer callback runs first. The Promise.then scheduled inside it appears only after JavaScript gets a microtask turn.

This is why async fake-timer helpers exist: they tick timers and then let promise callbacks settle.
Fake clock lab: retry with backoff
Step 0 of 7Ready
Your turn: follow the blue line

A reliable fake clock for promise-heavy code must run due timers and then give promise callbacks a microtask turn before continuing.

Running in
  1. script
Next: line 1
Click the blue line to take the next stepPop out in the code editor (opens in a new tab)JavaScript
  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(" -> "));})();
CallStoreChangeResultRun = next line. Ran = already executed.
Recent returnsNothing yet. Start with the blue line.
A guided replay recorded from real JavaScript calls, not an engine debugger. Step follows executed statements; Back reviews a snapshot. Reset starts a fresh run.

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.

The same retry run as one console line
FakeClock with advanceAsyncPop out in the code editor (opens in a new tab)JavaScript
class 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(" -> "));})();
Outputno real sleep
eventsRunning...
Try it yourself
advanceAsync

The clock jumps from 0 to 100 to 300, and the promise chain has a chance to continue after each due timer.

A real sleep-based test would wait 300ms or more. This one finishes as fast as promises settle.
Timer APIs you will meet
EnvironmentAPI namesImportant detail
Node 22 node:testmock.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.
Vitestvi.useFakeTimers() and await vi.advanceTimersByTimeAsync(ms).The async helper advances timers and lets promise callbacks settle.
Jestjest.useFakeTimers() and await jest.advanceTimersByTimeAsync(ms).Use the async helper for promise-heavy timer code.
Hand-rolled fake clockInject 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 22 node:test mock timersJavaScript
import { 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.

Vitest and Jest async timer helpersJavaScript
// 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 FIRST

Events 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.

Wait for an event with a promise
Promise wrapper for an eventPop out in the code editor (opens in a new tab)JavaScript
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));
Resolved detailevent
event.detail.idWaiting...
Try it yourself
EventTarget

The test starts waiting before dispatching the event. { once: true } cleans up after the first event, and AbortSignal.timeout gives the wait a deadline.

Use this shape for DOM events, Web APIs, and Node EventEmitter events through 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.

Node: events.once with a timeout signalJavaScript
import { 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);
A per-test timeout is a deadline, not a sleepJavaScript
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 IT

Flaky 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.

Flake causes and better first fixes
CauseSmellFirst repair
Real sleepawait sleep(1000)Use fake time, a promise for the exact event, or a polling helper with a short timeout.
Shared stateOne test leaves data for the next test.Create fresh fixtures in each test and clean up after external resources.
Order dependenceThe suite passes alone but fails after another file.Avoid global mutation and run focused tests in random order while fixing.
Network or clockCI is slower, offline, in another time zone, or on another date.Inject clients, freeze dates, set time zones, and use recorded responses.
Race conditionThe 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.
Match the flake to the first fix
  • `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
Try it yourself
0 of 6 correct

Sort each flaky symptom by the deterministic repair you should try before retries.

Choose a category for every card. You can change an answer at any time; Reset clears them all.
Retries are the last knob

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.
Similar tools, different jobs
ToolGood useCommon mistake
async testReturn a promise the runner waits for.Start async work but forget to await it.
Fake timersDrive timeout and interval behavior instantly.Assert before microtasks scheduled by timer callbacks flush.
TimeoutFail a stuck wait quickly.Use it as a sleep that guesses success.
RetryContain rare external instability after deterministic fixes.Hide a race or shared-state leak.

Practice exercises

5 EXERCISES
Exercise 1 · Warm-upSpot the false pass

Predict the first test-result text that appears before the late assertion updates the log.

Starter codePop out in the code editor (opens in a new tab)JavaScript
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);

Answer, then press Check. Spacing and letter case don’t matter.

    Exercise 2 · PracticeChoose the rejection helper

    In a Node test, what helper should check that loadUser() rejects with missing user?

    Answer, then press Check. Spacing and letter case don’t matter.

      Exercise 3 · PracticeTrace the fake clock result

      Use the backoff order from the lesson fake clock. What value does the retry promise finally resolve to?

      Starter codePop out in the code editor (opens in a new tab)JavaScript
      // Expected event order from the lesson fake clock:
      // try 1 at 0 -> try 2 at 100 -> try 3 at 300 -> ok

      Answer, then press Check. Spacing and letter case don’t matter.

        Exercise 4 · PracticeWait for the event detail

        Predict the value logged by the event promise.

        Starter codePop out in the code editor (opens in a new tab)JavaScript
        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));

        Answer, then press Check. Spacing and letter case don’t matter.

          Exercise 5 · ChallengeFix the flaky UI wait

          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?

          Answer, then press Check. Spacing and letter case don’t matter.

            Check your understanding

            7 QUESTIONS
            Lesson quiz · 7 questionsScore: first tries count
            1. Question 1 of 7What is the safest default shape for a modern promise-based test?

              Choose an answer to see the explanation.

            2. Question 2 of 7What does this tiny harness print first?

              Read the code, then predictPop out in the code editor (opens in a new tab)JavaScript
              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[0]), 0);

              Choose an answer to see the explanation.

            3. Question 3 of 7Which assertion correctly verifies a promise rejection in Node?

              Read the code, then predictJavaScript
              async function saveInvoice() {
                throw new Error("offline");
              }

              Choose an answer to see the explanation.

            4. Question 4 of 7What does this timer-and-promise example print first?

              Read the code, then predictPop out in the code editor (opens in a new tab)JavaScript
              const 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.

            5. Question 5 of 7Why does advanceTimersByTimeAsync matter in Vitest and Jest?

              Choose an answer to see the explanation.

            6. Question 6 of 7What is the best way to test a one-time event?

              Choose an answer to see the explanation.

            7. 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 async tests, returned promises, or carefully wired legacy done callbacks; never start async work and return undefined.
            • Use assert.rejects or expect(...).rejects for 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.

            CompleteFrontend Clear concepts. Working examples.