completefrontend
Open lab My progress
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 the real workReturn promises or use async tests, and spot a false pass: a test that passes before its check runs.
  • 02
    Check failures and control timeCheck rejected promises the right way, and use fake clocks that let promise callbacks run between timers.
  • 03
    Fix flaky tests instead of hiding themReplace sleeps, shared data, real network calls, dates, random values, and races with things the test controls.

A good async test waits for the work to finish

Async code is code that finishes later. It uses promises, timers, events, or streams. A test for it has one job: wait until that work is done, then check the result.

Two words come up all the time in this lesson:

  • A test runner is the tool that runs your tests and reports PASS or FAIL. Examples are node:test, Vitest, and Jest.
  • An assertion is a check, like "the cart has 3 items". If the check is wrong, it throws an error, and the test fails.

Waiting longer does not make an async test good. The test must give the runner the exact thing that says "the work is done". Most often, that thing is a promise.

Definition

Testing async code means three steps. Start the async work. Give the runner the promise (or event, timer, or stream end) that stands for that work. Check the result before the runner decides that the test passed.

Real-life analogyA relay race with a real baton

In a relay race, the judge does not end the race when an athlete starts running. The judge waits until the baton crosses the finish line. If an athlete drops the baton, the judge has nothing to wait for.

In real life: An athlete hands over the baton
In JavaScript: The test returns or awaits the promise
In real life: The judge waits for the baton to cross the line
In JavaScript: The test runner waits for that promise
In real life: A dropped baton gives the judge nothing to wait for
In JavaScript: A forgotten promise lets the test end before the check runs

The table shows four ways to write a test body. Read each row across to see whether the runner waits.

Does the runner wait? It depends on what the test gives back
What the test body doesWhat the runner doesWhen to use it
It returns a promise.The runner waits until that promise succeeds or fails.Use it for promise chains and helper functions.
It is an async function.The function gives back a promise by itself, so the runner waits.This is the clearest choice for new tests.
It takes a done callback.Some runners wait until you call done() or done(error).Older Jest and Mocha tests use it. Prefer promises with node:test.
It starts work but returns nothing.The runner gets undefined and can finish at once.Never. This is the false pass bug.

This lesson builds on promises, async/await, microtasks, the event loop, timers, promise errors, the mocks and fakes lesson, and cancellation.

The false pass: a test that ends too early

STEP THROUGH

Here is the most common async test bug, step by step:

  1. The test starts some async work. The check sits inside that work.
  2. The test does not return the promise, and does not await it.
  3. So the runner gets undefined back. To the runner, that means "nothing to wait for".
  4. The runner marks the test as passed. The check runs later, after the result is already written.

This is called a false pass. If the late check fails, its error may show up on its own, or a catch may only log it. Either way, the PASS was not real.

Return the promise that holds the checkPop out in the code editor (opens in a new tab)JavaScript
const loadCart = () => Promise.resolve(3);
const testCart = () => loadCart().then((items) => {
  if (items !== 3) throw new Error("wrong cart");
  console.log("cart has 3 items");
});
testCart();

loadCart() gives back a promise of 3. testCart returns the chain made by .then(...), and the check sits inside that chain. A runner that gets testCart as the test body waits for that chain. The cart has 3 items, so it prints cart has 3 items.

In a real runner, pass testCart itself as the test body. Don't call it yourself first.

An async test that forgets to awaitPop out in the code editor (opens in a new tab)JavaScript
const loadCart = () => Promise.resolve(3);
async function testCart() {
  loadCart().then((items) => console.log("late: " + items));
  console.log("test body done");
}
testCart();

Line 3 starts a promise, but does not await it or return it. Line 4 prints test body done first. Only after that does the .then callback print late: 3.

The async function does give back a promise. But that promise does not include the cart check, so a runner would not wait for the check.

Left: forgot to await. The test body returns undefined, so the runner says PASS too soon and the check runs later. Right: an async body that awaits. The test body returns a promise, so the runner waits and the wrong value makes the test fail, which catches the bug.forgot to awaittest bodyreturnsundefinedfalse PASSthe check runs laterasync body, awaittest bodyreturnsa promisebug caughtit waits, then says FAIL
Left: the body starts a promise but gives back nothing, so the runner says PASS before the check runs. Right: the async body gives back a promise, so the runner waits and the bug fails the test.

The step-through below uses a tiny test runner, called a harness. It does four things:

  1. It calls the test body.
  2. It checks whether the body gave back a promise (something with a .then method).
  3. If so, it awaits that promise.
  4. Then it logs PASS, or FAIL with the error message.

Real runners such as node:test, Vitest, and Jest do the same check around your test function.

Step through: does the check count?
Step 0 of 6Ready
Your turn: follow the blue line

The same bug, two ways. The code under test is the same. Only the returned promise decides whether the check counts.

Running in
  1. script
Next: line 1
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);
CallStoreChangeResultBlue line: runs next. Ran: already executed.
Recent returnsNothing yet. Start with the blue line.
Choose how the test body is written

The harness waits only when the test body gives back a promise. Changing the choice starts the replay again.

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.
Change one thing: false pass, real failure, real pass
A tiny 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 check reports the bug, after the test result is already written.

The code and output come from the lesson's tiny test harness. Nothing sleeps for real.
Which test shape to use

Make the test function async, and await the work you check. Returning a promise chain also works. Older Jest and Mocha tests may use a done callback instead: the runner waits until you call done(), and you call done(error) when something fails. With node:test, prefer promises.

The value a promise gives when it succeeds is its resolved value. Check it directly, for example with assert.equal(await loadName(), "Ada") or await expect(loadName()).resolves.toBe("Ada"). What matters is that the check sits inside the promise the runner waits for.

Testing a promise that should fail

REAL NODE OUTPUT

A promise that fails is called rejected. A rejection is not the same as a normal throw. It happens later, inside the promise. So you need checks made for promises. A good rejection check does two things:

  • It fails the test if the promise succeeds instead.
  • It checks that the error is the one you expected, by its message or its type.
Read the error from a rejected promisePop out in the code editor (opens in a new tab)JavaScript
const saveCart = () => Promise.reject(new Error("offline"));
saveCart().catch((error) => {
  console.log(error.message === "offline" ? "expected: offline" : "wrong error");
});

saveCart rejects with an error whose message is offline. The .catch callback checks the message and prints expected: offline.

This is only a demo, not a full test. If the promise succeeded, the callback would never run, and nothing would fail. A real test needs a check that fails when the promise succeeds.

A catch that accepts the wrong errorPop out in the code editor (opens in a new tab)JavaScript
const saveCart = () => Promise.reject(new Error("permission denied"));
saveCart().catch(() => console.log("caught something"));

Now saveCart rejects with permission denied. The .catch callback ignores the error and still prints caught something. A good rejection check asks two questions: did the promise reject, and was it the right error?

Left: saveCart rejects with permission denied. A catch that accepts any error prints caught something, so the wrong error passes. Right: saveCart rejects with offline. A catch that checks the message prints expected: offline, so the right error is checked.catch anythingpermission deniedany errorconsolecaught somethingwrong error passescheck the messageofflineis it offline?consoleexpected: offlineright error checked
Left: the catch accepts any error, so permission denied still prints caught something. Right: the catch checks the message, so it knows the error is offline.

The table shows three ways to check a rejection. The last column says what each one protects you from.

Ways to check that a promise rejects
WhereWhat to writeWhy it is safer
Nodeawait assert.rejects(promise, /message/)It fails if the promise succeeds, or rejects with the wrong error.
Vitest and Jestawait expect(promise).rejects.toThrow(/message/)Keep the await in front. Without it, the test may not wait for the check.
By hand with try/catchPut a clear failure after the awaited call, outside the try.A loose catch can catch your own assert.fail() and turn it into a false pass.

Here is a full Node test. Line 9 awaits assert.rejects. It waits for the promise, fails if the promise succeeds, and checks the message against /offline/. That is a pattern that matches any message containing the word offline.

A Node test with 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/);});

Below is the real output from running that file with node --test. It uses TAP, a plain text format for test results. ok 1 and # pass 1 mean the test passed. The times show <normalized> because they change on every run.

What Node prints when that test runstext
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 write the check the same way. Put await in front of expect. Without it, the test can end before the check runs.

The same check in Vitest and JestJavaScript
// Vitestawait expect(saveInvoice()).rejects.toThrow(/offline/); // Jestawait expect(saveInvoice()).rejects.toThrow(/offline/);
A rejection check must fail when the promise succeeds
A small rejection checkPop 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
two promises

The first promise rejects with the expected message, so the check passes. The second promise succeeds, so a good rejection check reports FAIL instead of passing quietly.

This small helper does the same job as assert.rejects and expect(...).rejects.

You can also write the check by hand with try/catch, but it is easy to get wrong. Here is the common mistake:

  1. Inside the try, you put assert.fail() or throw new Error("expected rejection"), to fail the test if the promise succeeds.
  2. The promise succeeds, so that line throws.
  3. The catch catches that very error and treats it as the expected rejection.
A try/catch that catches its own failurePop 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.");  }})();

shouldReject succeeds, so line 8 throws expected rejection to fail the test. But the catch on line 9 catches that error. Line 10 prints caught expected rejection, and a loose test would call that a pass.

An uncaught rejection means the test didn't wait

A rejection that no code catches is an unhandled rejection. Depending on the runner and its settings, it may fail the whole run, or show up after its test has already ended. Either way, the test did not wait for that work. Return the promise, await it, or check the rejection directly.

Fake timers and promise callbacks

MICROTASKS

A fake timer, or fake clock, replaces the real clock in a test. The test does not wait 200 real milliseconds. It says "move time forward 200ms", and every timer due by then runs at once.

But JavaScript keeps two separate waiting lines here:

  • Timer callbacks wait in the task queue. Moving a fake clock forward runs them.
  • Promise callbacks (.then callbacks, and code after await) wait in the microtask queue. They run only after the current callback finishes.

The moment JavaScript runs the waiting promise callbacks is called a microtask turn. A timer callback can schedule a promise callback that has not run yet. If you check too early, the code looks broken. It isn't; the promise callback is only waiting for its turn.

A timer callback that schedules a promise callbackPop out in the code editor (opens in a new tab)JavaScript
const events = [];
function timerFired() {
  events.push("timer");
  Promise.resolve().then(() => events.push("promise"));
}
timerFired();
console.log(events.join(" -> "));
queueMicrotask(() => console.log(events.join(" -> ")));

timerFired stands in for a callback that a fake clock runs. Line by line:

  1. Line 3 adds timer to the events list right away.
  2. Line 4 schedules a promise callback that will add promise later.
  3. The first log, on line 7, prints timer.
  4. Line 8 uses queueMicrotask(fn), which puts fn in the microtask queue behind the promise callback. So the second log prints timer -> promise.
Left: at the first log, the events list holds only timer, so it prints timer. An arrow marked microtask turn leads right. Right: at the second log, the events list holds timer and promise, so it prints timer -> promise.first logtimereventstimermicrotask turnsecond logtimerpromiseeventstimer -> promise
The timer callback adds timer at once. The promise callback adds promise only after a microtask turn, so only the second log sees it.
Moving fake time does not run promise callbacks by itself
A 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(" -> ")));
What it logsreal microtasks
Try it yourself
timer, then microtask

The fake timer callback runs first. The .then callback it schedules runs only after a microtask turn.

That is why async fake timer helpers exist: they run the timers, then let promise callbacks run.

Next, a fake clock tests real-looking code: retryWithBackoff. It tries some work. If the work fails, it waits 100ms and tries again. If that fails too, it waits 200ms and tries a third time. Waiting longer after each failure is called backoff.

Step through: a retry with a fake clock
Step 0 of 7Ready
Your turn: follow the blue line

A fake clock for promise code must run the timers that are due, then give promise callbacks a microtask turn, before it moves on.

Running in
  1. script
Next: line 1
  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(" -> "));})();
CallStoreChangeResultBlue line: runs next. 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 fake clock in this lesson is a small teaching model, not a full timer library. It has two parts:

  • delay(ms) gives back a promise. It resolves when fake time reaches that point.
  • advanceAsync(ms) moves fake time forward. After each timer that is due, it awaits one microtask turn, so promise callbacks can run.

That is enough for this retry example. Longer promise chains may need more turns. For real test suites, use your runner's async timer helpers.

The same retry run, as one console line
A fake clock 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 waiting
eventsRunning...
Try it yourself
advanceAsync

The clock jumps from 0 to 100 to 300. After each timer that is due, the promise chain gets a turn to go on.

A test that sleeps would wait 300ms or more. This one finishes as fast as the promises do.

Debounce means: wait until calls stop for a while, then run only the last one. A search box uses it so it doesn't search on every key press. To test it, check both sides of its deadline, and check that the older call was cancelled.

Test a debounced search with a fake clockPop out in the code editor (opens in a new tab)JavaScript
let now = 0;
let jobs = [];
const clock = {
  set(callback, ms) { const job = { at: now + ms, callback }; jobs.push(job); return job; },
  clear(job) { jobs = jobs.filter((item) => item !== job); },
  advance(ms) { now += ms; const due = jobs.filter((job) => job.at <= now); jobs = jobs.filter((job) => job.at > now); due.forEach((job) => job.callback()); },
};
let pending;
function search(term) {
  clock.clear(pending);
  pending = clock.set(() => console.log("search " + term), 200);
}
search("t"); search("tea");
clock.advance(199);
console.log("before deadline");
clock.advance(1);

Line 13 calls search("t"), then search("tea"). The second call cancels the job for t. At 199ms, line 15 prints before deadline; the search has not run yet. At 200ms, line 16 runs the job, which prints search tea once.

There is no real waiting and no retry. In a real test, reset the fake clock after each test.

The table lists the timer tools you are likely to meet. The last column is the detail people most often miss.

Fake timer tools in Node, Vitest, and Jest
WhereWhat to callWhat to know
Node 22 node:testmock.timers.enable({ apis: ["setTimeout", "Date"], now }), then tick() or runAll().Date is faked only if you list it, or if you turn on the default set. This API is still experimental.
Vitestvi.useFakeTimers(), then await vi.advanceTimersByTimeAsync(ms).The async helper moves time and lets promise callbacks run.
Jestjest.useFakeTimers(), then await jest.advanceTimersByTimeAsync(ms).Use the async helper when timer code uses promises.
Your own fake clockPass a clock with delay(ms) into your code, and test with advanceAsync(ms).This works best when you own the code and don't want to fake the global timers.

Node 22 has four functions for fake timers:

  • mock.timers.enable turns fake timers on.
  • mock.timers.tick(ms) moves fake time forward.
  • mock.timers.runAll() runs every waiting timer.
  • mock.timers.reset() turns fake timers off again.
Fake timers in a Node 22 testJavaScript
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();});

Line 6 lists what to fake: setTimeout and Date. If you list only setTimeout, Date.now() keeps reading the real clock. Line 13 moves time 50ms, so the timer runs and records 1767225600050: the start time from line 7, plus 50.

Vitest and Jest use the same name: advanceTimersByTimeAsync. It moves fake time and lets promise callbacks run in between. Always await it.

Async timer helpers in Vitest and JestJavaScript
// 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

An event is a signal, like a click. A listener only hears events that happen after it starts listening. So an event test follows three steps:

  1. Start listening first.
  2. Make the event happen second.
  3. Give the wait a deadline, so a missing event fails fast instead of hanging.
Listen before clickingPop out in the code editor (opens in a new tab)JavaScript
const button = new EventTarget();
const clicked = new Promise((resolve) => {
  button.addEventListener("click", () => resolve("clicked"), { once: true });
});
button.dispatchEvent(new Event("click"));
clicked.then((value) => console.log(value));

button is an EventTarget: a plain object that can send and hear events. Line 3 adds the listener. { once: true } removes it after the first event. Line 5 sends the click with dispatchEvent. The promise resolves, so line 6 prints clicked.

If you send the click before adding the listener, nobody hears it. The promise then waits forever.

Left: listen first. Step 1 adds the listener, step 2 sends the click, and the promise prints clicked. Right: click first. Step 1 sends the click before anyone listens, step 2 adds the listener too late, and the promise waits forever.listen first1. listen2. clickprints clickedclick first1. click2. listenwaits forever
Left: the listener is ready, so it hears the click and the promise prints clicked. Right: the click happens before anyone listens, so the promise waits forever.

The longer example below wraps this in a helper called waitForEvent. It adds a deadline with an abort signal, an object that can say "stop now". AbortSignal.timeout(50) makes a signal that fires after 50ms.

Wait for an event with a promise
A promise that waits 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) => {    if (signal?.aborted) { reject(signal.reason); return; }    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));
What the promise givesevent
event.detail.idWaiting...
Try it yourself
EventTarget

The test starts waiting before it sends the event. { once: true } removes the listener after the first event, and AbortSignal.timeout(50) gives the wait a deadline.

Use this for page events and other browser events. For Node's EventEmitter, use events.once.

Two details keep the helper safe:

  • If the signal has already fired when the wait starts, line 4 rejects at once.
  • If the signal fires later, line 8 removes the event listener too, so no listener is left behind.

In the browser, an EventTarget has addEventListener. In Node, many objects use EventEmitter instead. Node's events.once gives you the same kind of promise for an EventEmitter.

A stream sends data in pieces. Node streams are event emitters, and they usually end with one of these events: end, finish, close, or error. Wait for the one that matches what your test checks.

Wait for a Node event with a deadlineJavaScript
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 test timeout is a deadline, not a waitJavaScript
import test from "node:test"; test("polls until ready", { timeout: 100 }, async () => {  await waitForReadyState();});

AbortSignal.timeout(50) stops one wait. A runner timeout, such as test(name, { timeout: 100 }, async () => ...), fails the whole test if it takes longer than 100ms. Both are deadlines: they say when to give up. A sleep is different. It guesses when the work will be done.

Fix flaky tests by removing guesses

SORT IT

A flaky test passes sometimes and fails other times, with no change to the code. Flaky async tests usually come from one of these causes:

  • real timers, or sleeps that guess how long to wait;
  • data shared between tests;
  • tests that only pass when they run in a certain order;
  • real network calls;
  • time zones and dates;
  • random values;
  • race conditions, where two things finish in an order you can't predict.

Fix the cause, so the test gets the same result on every run. Reach for retries only after that.

Wait for the save, not for a set timePop out in the code editor (opens in a new tab)JavaScript
const screen = { text: "Saving..." };
const saved = Promise.resolve().then(() => { screen.text = "Saved"; });
console.log(screen.text);
(async () => {
  await saved;
  console.log(screen.text);
})();

Line 3 prints Saving.... Line 5 awaits the save promise, whose callback updates the screen. So line 6 prints Saved.

In a real screen test, wait for what the user sees, with a query like screen.findByText("Saved"). Don't guess a delay.

Each row of the table is one cause of flaky tests. Read across: what you notice, then the first fix to try.

Causes of flaky tests and what to fix first
CauseWhat you noticeWhat to try first
A real sleepThe test has await sleep(1000).Use fake time, or await a promise for the exact event.
Shared dataOne test leaves data behind for the next test.Make fresh data in each test, and clean up anything outside the test.
Test orderThe test passes alone but fails after another file runs.Stop changing global data. Run the tests in random order while you fix it.
Network or clockThe CI server is slower, offline, in another time zone, or on another date.Pass in fake clients, freeze the date, set the time zone, and use saved responses.
A raceThe check runs before the screen, stream, or callback is done.Await what the user sees, the event, or the end of the stream. Don't guess.
Match each flaky test to its first fix
  • `await sleep(500)` after clicking Save
  • A retry test waits 100ms, then 200ms, then 400ms
  • A cache at the top of a file keeps its data between tests
  • The expected label depends on today's date
  • The test calls the real payment test server
  • The check expects one exact random ID
Try it yourself
0 of 6 correct

Sort each flaky test by the fix you should try before adding retries.

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

A retry runs a failed test again. It can help when something outside your control is shaky, like a remote service. But retries also hide races, and they make failures slower. First await the right thing, fake or pass in the time, keep data separate per test, and control the network, random values, dates, and time zones.

Common misconceptions

  • “The test is marked async, so everything inside counts.” Only work you await, or a promise you return, is part of what the runner waits for.
  • “A sleep proves the screen or stream is done.” A sleep only proves that time passed. Wait for the visible result, the event, or the end signal.
  • “Fake timers run promise callbacks too.” Timer callbacks and promise callbacks wait in separate queues. Use the async timer helpers, or await a microtask turn.
  • “Any caught error means the rejection happened.” A loose catch can catch your own failing check. Use a rejection check, like assert.rejects.
  • “Retries fix flaky tests.” Retries only hide the problem. Removing the guess fixes the test.

These four tools are easy to mix up. Each row says what the tool is for, and how it is often misused.

Similar tools, different jobs
ToolWhat it is forCommon mistake
async testIt gives the runner a promise to wait for.Starting async work inside it, but not awaiting it.
Fake timersThey run timer code at once, with no real waiting.Checking before the promise callbacks from those timers have run.
TimeoutIt fails a stuck wait quickly.Using it like a sleep, and hoping the work is done by then.
RetryIt handles rare problems outside your code, after the other fixes.Hiding a race, or data that leaks between tests.

Practice exercises

5 EXERCISES
Exercise 1 · Warm-upSpot the false pass

What test result does the harness add to the output first, before the late check adds its own line?

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, which helper checks 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 retry order from the lesson's 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

        What does the event promise log?

        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 screen wait

          A test for a Save button uses await sleep(500). It sometimes fails on the CI server, the machine that runs your tests after each push. Should the first fix be to add retries, or to 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 way to write a promise-based test today?

              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 check correctly tests a rejected promise 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 an event that fires only once?

              Choose an answer to see the explanation.

            7. Question 7 of 7A test passes on your laptop but fails on the CI server around midnight. What should you fix first?

              Choose an answer to see the explanation.

            Key takeaways

            • An async test is only trustworthy if the runner waits for the promise, event, timer, or stream that holds the check.
            • Use async tests or returned promises. In older suites, call done carefully. Never start async work and give back undefined.
            • Check rejected promises with assert.rejects or expect(...).rejects. An unhandled rejection means some work got away from the test.
            • Fake timers don't run promise callbacks by themselves. Use the async timer helpers, or a clock with advanceAsync.
            • Fix flaky tests by removing guesses: fake the time, keep data separate, control outside inputs, and await the right signal.

            Remember the one-liner.
            A reliable async test waits for the work to finish, not for a guessed amount of time.

            Up next: Testing the DOM & end to end.

            CompleteFrontend Clear concepts. Working examples.