Always async
Every async test is it('...', async () => ...). Shape the callback to be awaited even before you need it.
Writing Vitest async assertions whose green is earned, with awaited resolves and rejects, fake timers, and microtask draining.
A /lib function, refundCharge, returns a promise. Someone tested it like this:
it('refunds a settled charge', async () => { expect(refundCharge(charge)).resolves.toBe(true);});The test is green, but the function is broken: it resolves false for every charge, because a typo checks the refund against the wrong status. The refund button does nothing in production, and the test that was supposed to catch that reported success on every CI run for three weeks.
The bug isn’t really in the function. It’s in the missing await. You already write async/await in your app code, and you’ve spent this chapter testing pure functions, but those were synchronous, and synchronous code can’t lie to the runner about whether it finished. Async code can. The new idea is narrow, and it underwrites every async test you will ever write: the test runner can only judge what it waits for. An assertion it never waited for cannot fail, no matter how wrong the code is.
By the end of this lesson you’ll write async tests whose green is earned. You’ll learn the canonical awaited shape, the insurance that protects error tests with branches, and the genuinely hard part: what happens when fake timers and promises share a test. The course runs on Vitest 4 , which now hard-fails the exact mistake above instead of just warning. That hard-fail is a safety net, not the lesson. The lesson is understanding why the mistake is a mistake, so you write the awaited shape by habit and don’t depend on a tool noticing for you.
Before reaching for a fix, look closely at the failure itself, because it’s the reason the rest of this lesson exists. Here is the pairing that shipped the broken refund.
export async function refundCharge(charge: Charge): Promise<boolean> { const refund = await gateway.refund(charge.id); return refund.status === 'pending';}A one-word bug. A successful refund settles with status 'succeeded', never 'pending', so this returns false for every real refund. It’s exactly the kind of typo a test exists to catch.
it('resolves true when the charge is refunded', async () => { expect(refundCharge(settledCharge)).resolves.toBe(true);});Green against broken code. expect(...).resolves.toBe(true) builds a promise that will reject when the assertion fails, but nobody awaits it. The it callback runs to its closing brace and returns, the runner sees a callback that finished cleanly, and it reports a pass. The assertion’s rejection lands a tick later, with nothing left to catch it.
That second tab is the canonical version of the trap, but it’s not the only shape it takes. A forgotten await produces a silent pass in three different-looking ways, and recognizing all three is what keeps you from writing one by accident.
// 1. Dangling assertion — the .resolves promise is built but never awaited.expect(promise).resolves.toBe(x);
// 2. Comparing the wrong thing — a Promise object is never === x, but the// failed comparison is itself an unawaited promise, so it never surfaces.expect(promise).toBe(x);
// 3. Fire-and-forget — the promise is neither returned nor awaited; the test// body finishes before it settles.it('...', () => { doAsyncWork(); });All three look different on the surface, but the same mechanism is underneath, and this one rule covers all of them: the runner judges a test by the promise the it callback returns, or by the work you await inside it. Work it never waited for cannot fail the test. A silent pass is that rule turned against you.
To make the rule stick, predict what the runner reports for the broken pairing. Predict the verdict on the test itself, not what the function prints.
You're predicting the test runner's verdict, not program output. The runner here is Vitest 3, which only warns about an unawaited assertion. Type PASS or FAIL. Predict what this program prints, then press Check.
// refundCharge resolves false for every charge — the status check is wrong.async function refundCharge(charge: Charge): Promise<boolean> { const refund = await gateway.refund(charge.id); return refund.status === 'pending'; // bug: a settled refund is 'succeeded'}
it('resolves true when the charge is refunded', async () => { expect(refundCharge(settledCharge)).resolves.toBe(true);});PASS — against code you can see is broken. expect(refundCharge(...)).resolves.toBe(true) builds a promise that rejects when the value isn’t true, but the it callback never awaits it. The callback runs to its closing brace and returns, the runner sees a callback that finished cleanly, and it records a pass — a tick before the rejection lands, with nothing left to catch it. That’s the silent pass. Vitest 4 flips this exact case to a hard FAIL precisely because so many real bugs shipped behind it; the await fix in the next section makes it pass for the right reason.
Hold onto the surprise of that result: a PASS against code you can see is wrong. Every technique below exists to make sure your green means what you think it means.
await expect(p).resolves, the canonical formThe fix is one keyword, and it gives you the shape that roughly nine out of ten async tests take. Here it is against the broken version from above, once you fix the function’s typo too.
it('resolves true when the charge is refunded', async () => { await expect(refundCharge(settledCharge)).resolves.toBe(true);});Three things are doing work in that line, and in its mirror image for the failure path, await expect(p).rejects.toThrow(...). It’s worth knowing which piece does what, because when an async test misbehaves, one of these three is almost always missing.
it('resolves true when the charge is refunded', async () => { await expect(refundCharge(settledCharge)).resolves.toBe(true);});
it('rejects when the charge is already refunded', async () => { await expect(refundCharge(refundedCharge)).rejects.toThrow(RefundError);});.resolves and .rejects unwrap the promise and apply the matcher to the settled value: the resolved value for .resolves, the thrown error for .rejects. Without them you’d be matching against the Promise object itself.
it('resolves true when the charge is refunded', async () => { await expect(refundCharge(settledCharge)).resolves.toBe(true);});
it('rejects when the charge is already refunded', async () => { await expect(refundCharge(refundedCharge)).rejects.toThrow(RefundError);});The outer await is what makes the runner wait. Drop it and .resolves hands back a promise that resolves or rejects entirely outside the runner’s view, which is the silent pass from the last section.
it('resolves true when the charge is refunded', async () => { await expect(refundCharge(settledCharge)).resolves.toBe(true);});
it('rejects when the charge is already refunded', async () => { await expect(refundCharge(refundedCharge)).rejects.toThrow(RefundError);});async () => on the it callback is what gives the runner a promise to await. Default every async test to an async callback even when a particular line doesn’t strictly need it: the day you add an await inside, the callback is already shaped to be waited on.
You’ll see one alternative in the wild: return expect(p).resolves.toBe(x) instead of await. It works in both Vitest 3 and 4, because returning the assertion’s promise hands the runner the same thing the await does. The course prefers await for one reason that shows up the moment a test grows.
// `return` works — but you only get to return once.return expect(refundCharge(charge)).resolves.toBe(true);
// `await` composes — two awaited assertions in one test, no contortion.await expect(refundCharge(chargeA)).resolves.toBe(true);await expect(refundCharge(chargeB)).rejects.toThrow(RefundError);One more thing trips people up the first time, so let’s clear it up: .resolves and .rejects don’t come with their own matchers. They unwrap the promise, then hand off to the same matchers you already use on synchronous values, including toBe, toEqual, and toMatchObject. await expect(p).resolves.toEqual({ ok: true, data }) is the synchronous expect(value).toEqual(...) with a promise-unwrapping step bolted onto the front. There’s nothing new to learn about matchers here. Async only changes how the value arrives, not how you assert on it once it has.
To check that the chain of waiting has settled in your head, put the steps the runner takes for a correct awaited test in order.
A correct async test runs through these steps in order. Drag them into the order the runner executes them. Drag the items into the correct order, then press Check.
it('resolves true when the charge is refunded', async () => { await expect(refundCharge(settledCharge)).resolves.toBe(true);});it callback. refundCharge(settledCharge) starts and returns a pending promise. .resolves waits for that promise to settle, then targets its resolved value. await pauses the callback until the assertion finishes. toBe(true) compares the resolved value and passes or fails. expect.assertions(n) for branchy tests.resolves and .rejects give you the everyday shape. But some error tests don’t have a single straight line for the runner to await: they branch through a try/catch, and a branch is exactly where a silent pass hides again, in a new disguise.
Picture a test that wraps the call in try/catch so it can inspect the thrown error in detail. If the call doesn’t throw, because the bug you’re testing for has been reintroduced, control skips the catch block entirely, the test runs zero assertions, and it passes having verified nothing at all. This is the same problem as the forgotten await: the runner judged a callback that finished cleanly, and finishing cleanly was the bug.
expect.assertions(n) is the cheap insurance against that. It declares up front that the test must run exactly n assertions. If the test exits having run fewer, whether from a swallowed error, an early return, or a catch block that never executed, it fails with expected n assertions, called m. There’s also a looser expect.hasAssertions() that only checks at least one assertion ran. Reach for the precise count whenever you know it, since “called 1 of 2” catches a half-finished test that “at least one” waves through.
Here’s the canonical branchy error test, the shape expect.assertions exists for.
it('rejects with code expired when the charge has lapsed', async () => { expect.assertions(2); try { await refundCharge(expiredCharge); expect.fail('expected refundCharge to reject'); } catch (err) { expect(err).toBeInstanceOf(RefundError); expect((err as RefundError).code).toBe('expired'); }});expect.assertions(2) is the contract: this test must run exactly two assertions before it ends. Forget the await, swallow the error, or skip the catch, and the count comes up short, so the test fails loudly.
it('rejects with code expired when the charge has lapsed', async () => { expect.assertions(2); try { await refundCharge(expiredCharge); expect.fail('expected refundCharge to reject'); } catch (err) { expect(err).toBeInstanceOf(RefundError); expect((err as RefundError).code).toBe('expired'); }});The await runs the call inside the try. If refundCharge resolves instead of rejecting (the no-throw bug), control falls through to the next line instead of jumping to catch. expect.fail('...') guards that case: a non-throwing bug fails the test outright rather than silently skipping the assertions.
it('rejects with code expired when the charge has lapsed', async () => { expect.assertions(2); try { await refundCharge(expiredCharge); expect.fail('expected refundCharge to reject'); } catch (err) { expect(err).toBeInstanceOf(RefundError); expect((err as RefundError).code).toBe('expired'); }});The two catch assertions inspect the error: its class, then its structured code field. Asserting on the class and the code rather than the message string is its own topic, covered in the next lesson, but notice that’s two assertions, which is what the contract on line 2 promised.
That block does double duty. It teaches expect.assertions, and it previews the try/catch error-inspection pattern. But notice the cost: it takes seven lines to assert on two fields of one error. So the decision rule is sharp. Reach for try/catch plus expect.assertions only when an error has several fields worth asserting and you want them all in one test. When you just need to confirm that something rejected with the right error type, the one-liner await expect(p).rejects.toThrow(RefundError) from the last section says it in a line. Don’t default to the heavy form.
This section is the hardest part of the lesson. You already know vi.useFakeTimers() from earlier in this chapter: how to freeze the clock, advance it with vi.advanceTimersByTime, and reset it in beforeEach/afterEach. That machinery still applies unchanged. One thing changes: when the code you’re advancing time through also awaits, the obvious approach quietly fails. Here it is failing.
const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));
export async function withRetry(): Promise<string> { await sleep(5000); return doWork();}it('runs the work after the backoff elapses', () => { withRetry(); vi.advanceTimersByTime(5000); expect(doWork).toHaveBeenCalled();});Fails: the assertion runs a tick too early. advanceTimersByTime fires the timer synchronously, so the very next line runs before withRetry’s await resumes. doWork hasn’t been called yet. (doWork is a spy from earlier in the file, so the assertion observes whether it ran.)
it('runs the work after the backoff elapses', async () => { withRetry(); await vi.advanceTimersByTimeAsync(5000); expect(doWork).toHaveBeenCalled();});Passes: the async variant drains the promise. advanceTimersByTimeAsync fires the timer and lets the awaited continuation run before returning, so by the time the assertion runs, doWork has been called.
The entire fix is one word, advanceTimersByTimeAsync instead of advanceTimersByTime, plus the await and async that word requires. But typing the fix from memory isn’t the goal. The goal is understanding why the sync version leaves the promise hanging, because that understanding generalizes far past fake timers. The reason is that the timer firing and the promise resolving happen on two different queues, one tick apart. Scrub through the trace below to see it.
setTimeout callback → resolves sleep doWork ran? no expect(doWork).toHaveBeenCalled() doWork ran? no — assertion fails await advanceTimersByTimeAsync(5000) doWork() doWork ran? yes advanceTimersByTime stops after the macrotask advanceTimersByTimeAsync macrotask + drains the microtasks The takeaway: a macrotask firing and the microtask it schedules are one tick apart, and the synchronous timer API stops after the macrotask. The async variant keeps going until the event loop has drained the microtasks too. That’s the same micro/macrotask split you walked through back in the event-loop lesson, and it’s why the rule is simple: any fake-timer test on a code path that awaits should use the *Async variant.
Here’s the family of timer methods you’ll reach for.
await vi.advanceTimersByTimeAsync(5000); // ↔ advanceTimersByTime — advance by a fixed spanawait vi.runOnlyPendingTimersAsync(); // ↔ runOnlyPendingTimers — one round; safe with setIntervalawait vi.runAllTimersAsync(); // ↔ runAllTimers — drains every timer; loops forever on a // self-rescheduling setInterval, so prefer the bounded formsThere’s a sibling case where there’s no timer to advance at all. Sometimes the code doesn’t use setTimeout: it schedules a bare microtask directly with queueMicrotask, or chains a .then, or awaits an already-resolved promise. There’s no clock to tick forward, so advanceTimersByTimeAsync has nothing to do. To let those microtasks drain, await Promise.resolve() yields one tick to the event loop:
await Promise.resolve(); // yield one tick — pending microtasks drainUse this one sparingly. It’s tempting to sprinkle await Promise.resolve() (sometimes twice, for two ticks) wherever a test needs something to happen, but that’s brittle, because you’re guessing at how many ticks the code takes instead of waiting for a real signal. Reach for the microtask flush only when the code genuinely schedules a bare microtask and there’s no actual promise to await. When there is a promise, such as a return value or a settled effect, await that instead. Name the real await point whenever you can.
Now work through that one-tick gap yourself, in plain promises you can actually run. The Vitest timer APIs can’t execute in this sandbox, but the mechanism the diagram showed is identical here: a microtask is queued, and the assertion runs before it drains, until you yield a tick.
A microtask is scheduled at module load to flip `ran` to true. `readFlag` returns `ran`, but right now it reads it before that microtask has drained, so the test fails. Make `readFlag` observe the resolved value by yielding a tick — `await Promise.resolve()` — before it returns. This is the diagram's microtask-flush idea in plain promises; the same reasoning is what `advanceTimersByTimeAsync` does for you.
A handful of async assertions round out what you’ll reach for in practice. Keep one thing in mind as you read them: how to assert on an error’s shape, whether by class, message, or structured code, is the next lesson’s whole subject. This section covers only the async mechanics, kept deliberately shallow.
The async negative form you’ve already seen is the one you’ll use most for failures:
it('rejects with a validation error for a malformed charge id', async () => { await expect(refundCharge(malformedCharge)).rejects.toThrow(RefundError);});Assert on the error class (or a structured code), not the message string, because messages get reworded and the test shouldn’t break when they do. That’s a one-line preview; the next lesson is where it’s earned. When an error has several fields worth checking at once, the try/catch plus expect.assertions block from earlier is the tool, and the async version is identical, with the await sitting inside the try.
Cancellation comes next, and it’s probably the first time you’ll test a behavior most people forget is theirs to test. If a /lib function takes an AbortSignal, and per the course’s conventions every async function that does IO takes one when cancellation is reachable, then the abort path is code you wrote, which means it earns an assertion just like the happy path does.
it('rejects when the caller aborts mid-flight', async () => { const controller = new AbortController(); const resultPromise = fetchInvoice('inv_1', { signal: controller.signal }); controller.abort(); await expect(resultPromise).rejects.toThrow(/abort/i);});Notice the matcher is loose: /abort/i, a case-insensitive regex, rather than an exact class or message. The native AbortError name and message vary a little across runtimes, so a loose match buys robustness without giving up the signal that something abort-shaped rejected the promise. That’s a reasonable trade here, but it’s not a license to be loose everywhere.
The last one handles parallel calls. When a unit fires several async calls at once and you want to assert on each outcome independently, so that one rejection doesn’t collapse the whole assertion into a single thrown error, Promise.allSettled is the production primitive, and the test mirrors it exactly:
it('settles each refund independently when one fails', async () => { const results = await Promise.allSettled([ refundCharge(chargeA), refundCharge(chargeB), refundCharge(badCharge), ]); expect(results.map((r) => r.status)).toEqual([ 'fulfilled', 'fulfilled', 'rejected', ]);});This is the same reasoning behind reaching for Promise.allSettled in production when one failure shouldn’t cancel the rest. The test asserts the outcomes the primitive guarantees: two settled, one rejected, and none of them taking down the others.
One async test’s mess can poison the next, and async makes the mess easier to leave behind. A resource a test opens, such as an AbortController, an interval, or fake timers, has to be released even when an assertion throws partway through and the rest of the test never runs. That’s what finally and afterEach are for.
beforeEach(() => { vi.useFakeTimers();});
afterEach(() => { vi.useRealTimers();});
it('stops polling once the invoice settles', async () => { const controller = new AbortController(); startInvoicePoll('inv_1', { signal: controller.signal }); try { await vi.advanceTimersByTimeAsync(3000); expect(onSettled).toHaveBeenCalled(); } finally { controller.abort(); }});The afterEach(() => vi.useRealTimers()) line is non-negotiable for async-timer tests, and it ties straight back to the timer mechanics from earlier in this chapter. Skip it and the fake clock leaks forward into whatever test runs next: a test that expects real time will hang waiting for a clock that no longer ticks on its own, or behave strangely, and the failure points at the innocent test instead of the one that forgot to clean up. This is the classic run-order bug, and the only reliable fix is to always restore real timers in teardown.
One smell worth naming while we’re here: a long testTimeout is not a fix. If a test needs a thirty-second timeout to pass, the await structure is wrong, usually a real timer that should have been faked, or a missing *Async flush leaving a promise hanging. Cranking the timeout hides the structural problem and makes the suite slower for everyone. Fix the await, not the clock.
Async testing is one idea in several forms. Here’s the durable set.
Always async
Every async test is it('...', async () => ...). Shape the callback to be awaited even before you need it.
The canonical form
Positive: await expect(p).resolves.toEqual(...). Negative: await expect(p).rejects.toThrow(Class). The outer await is mandatory.
Branchy error tests
Multi-field error inspection: expect.assertions(n) + try/catch + expect.fail. Insurance against a skipped branch.
Timers on an awaited path
The *Async variants fire the timer and drain microtasks. The sync ones stop after the timer. Use *Async whenever the path awaits.
Behaviors get tests
Cancellation (rejects.toThrow(/abort/i)) and parallel effects (Promise.allSettled) are code you wrote, so they earn assertions.
Clean up or leak
afterEach(() => vi.useRealTimers()) is non-negotiable. Release resources in finally so a thrown assertion can’t leave a mess.
And the one rule underneath all of them, the sentence to keep when the specifics fade: the runner can only judge what it awaits. Every technique here is a way of making sure it waits for the thing you actually care about.