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 the real workReturn promises or use
asynctests, and spot a false pass: a test that passes before its check runs. - 02Check failures and control timeCheck rejected promises the right way, and use fake clocks that let promise callbacks run between timers.
- 03Fix 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.
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.
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.
| What the test body does | What the runner does | When 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 THROUGHHere is the most common async test bug, step by step:
- The test starts some async work. The check sits inside that work.
- The test does not return the promise, and does not
awaitit. - So the runner gets
undefinedback. To the runner, that means "nothing to wait for". - 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.
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.
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.
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:
- It calls the test body.
- It checks whether the body gave back a promise (something with a
.thenmethod). - If so, it awaits that promise.
- 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.
The same bug, two ways. The code under test is the same. Only the returned promise decides whether the check 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);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 check reports the bug, after the test result is already written.
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 OUTPUTA 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.
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.
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?
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.
| Where | What to write | Why it is safer |
|---|---|---|
| Node | await assert.rejects(promise, /message/) | It fails if the promise succeeds, or rejects with the wrong error. |
| Vitest and Jest | await expect(promise).rejects.toThrow(/message/) | Keep the await in front. Without it, the test may not wait for the check. |
By hand with try/catch | Put 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.
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/);});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.
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.
// 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, so the check passes. The second promise succeeds, so a good rejection check reports FAIL instead of passing quietly.
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:
- Inside the
try, you putassert.fail()orthrow new Error("expected rejection"), to fail the test if the promise succeeds. - The promise succeeds, so that line throws.
- The
catchcatches that very error and treats it as the expected rejection.
try/catch that catches its own failureasync 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.
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
MICROTASKSA 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 (
.thencallbacks, and code afterawait) 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.
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:
- Line 3 adds
timerto theeventslist right away. - Line 4 schedules a promise callback that will add
promiselater. - The first log, on line 7, prints
timer. - Line 8 uses
queueMicrotask(fn), which putsfnin the microtask queue behind the promise callback. So the second log printstimer -> promise.
timer at once. The promise callback adds promise only after a microtask turn, so only the second log sees it.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 .then callback it schedules runs only after a microtask turn.
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.
A fake clock for promise code must run the timers that are due, then give promise callbacks a microtask turn, before it moves on.
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 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.
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. After each timer that is due, the promise chain gets a turn to go on.
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.
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.
| Where | What to call | What to know |
|---|---|---|
Node 22 node:test | mock.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. |
| Vitest | vi.useFakeTimers(), then await vi.advanceTimersByTimeAsync(ms). | The async helper moves time and lets promise callbacks run. |
| Jest | jest.useFakeTimers(), then await jest.advanceTimersByTimeAsync(ms). | Use the async helper when timer code uses promises. |
| Your own fake clock | Pass 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.enableturns 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.
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.
// 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 FIRSTAn 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:
- Start listening first.
- Make the event happen second.
- Give the wait a deadline, so a missing event fails fast instead of hanging.
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.
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.
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));Waiting...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.
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.
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);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 ITA 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.
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.
| Cause | What you notice | What to try first |
|---|---|---|
| A real sleep | The test has await sleep(1000). | Use fake time, or await a promise for the exact event. |
| Shared data | One test leaves data behind for the next test. | Make fresh data in each test, and clean up anything outside the test. |
| Test order | The 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 clock | The 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 race | The 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. |
`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
Sort each flaky test by the fix you should try before adding retries.
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
catchcan catch your own failing check. Use a rejection check, likeassert.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.
| Tool | What it is for | Common mistake |
|---|---|---|
async test | It gives the runner a promise to wait for. | Starting async work inside it, but not awaiting it. |
| Fake timers | They run timer code at once, with no real waiting. | Checking before the promise callbacks from those timers have run. |
| Timeout | It fails a stuck wait quickly. | Using it like a sleep, and hoping the work is done by then. |
| Retry | It handles rare problems outside your code, after the other fixes. | Hiding a race, or data that leaks between tests. |
Practice exercises
5 EXERCISESWhat test result does the harness add to the output first, before the late check adds its own line?
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 output gets PASS forgot first. The late check adds ASSERT ready only after the runner has already passed the test.
In a Node test, which helper checks that loadUser() rejects with missing user?
await assert.rejects(loadUser(), /missing user/);assert.rejects waits for the promise. It fails if the promise succeeds, and it checks the error message.
Use the retry order from the lesson's 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 waits, the third try returns ok. So the retry promise resolves to ok.
What does the event promise log?
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". The promise resolves with that detail, and the .then callback logs it.
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?
await screen.findByText('Saved');Remove the race by awaiting what the user sees. Use retries only as a last step, after the waits are exact.
Check your understanding
7 QUESTIONSQuestion 1 of 7What is the safest way to write a promise-based test today?
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 check correctly tests a rejected promise 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 an event that fires only once?
Choose an answer to see the explanation.
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
asynctests or returned promises. In older suites, calldonecarefully. Never start async work and give backundefined. - Check rejected promises with
assert.rejectsorexpect(...).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.