Skip to content
Chapter 87Lesson 5

Async tests without the forgotten-await trap

Writing async Vitest assertions that earn their green, 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: a typo checks the charge against the wrong status, so it resolves false every time. The refund button has done nothing in production for three weeks, and the test that should have caught it passed on every CI run.

The bug is the missing await. The pure functions you tested earlier this chapter can’t lie about whether they finished; async code can. One narrow rule fixes this: the runner can only judge what it waits for, so an assertion it never waited for cannot fail.

This lesson covers the canonical awaited shape, the assertion-count insurance for error tests with branches, and the hard case where fake timers and promises share a test. The course runs on Vitest 4 , which now hard-fails the mistake above, but the goal is to write the awaited shape by habit, not lean on the tool.

How a forgotten await passes a broken test

Section titled “How a forgotten await passes a broken test”

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.

That second tab is the trap in its most common form, but a forgotten await takes three shapes.

Three ways a forgotten await passes
// 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(); });

One rule covers all three: the runner judges a test by the promise the it callback returns, or the work you await inside it. Work it never waited for cannot fail the test. A silent pass is that rule turned against you.

Predict the runner’s verdict on the broken pairing, 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);
});

await expect(p).resolves, the standard async assertion

Section titled “await expect(p).resolves, the standard async assertion”

One keyword fixes it, and gives you the shape most async tests take:

it('resolves true when the charge is refunded', async () => {
await expect(refundCharge(settledCharge)).resolves.toBe(true);
});

Three things do the work in that line, and in its mirror for the failure path, await expect(p).rejects.toThrow(...). When an async test misbehaves, one of the 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 match 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 settles outside the runner’s view, 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 gives the runner a promise to await. Default every async test to an async callback even before a line needs it, so the day you add an await inside, the callback is already shaped to be waited on.

1 / 1

You’ll also see return expect(p).resolves.toBe(x) instead of await. It works, because returning the assertion’s promise hands the runner the same thing await does, but 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);

.resolves and .rejects bring no matchers of their own. They unwrap the promise, then hand off to the same matchers you use on synchronous values, like toBe, toEqual, and toMatchObject. Async changes how the value arrives, not how you assert on it.

.resolves and .rejects cover the common cases. But some error tests branch through a try/catch instead of running one straight line, and a branch is where a silent pass hides again. When a test wraps the call to inspect the thrown error in detail and the call doesn’t throw, control skips the catch, the test runs zero assertions, and it passes having checked nothing.

expect.assertions(n) is the cheap insurance. It declares that the test must run exactly n assertions; if it ends having run fewer, whether from a swallowed error, an early return, or a catch that never executed, it fails with expected n assertions, called m. The looser expect.hasAssertions() only checks that at least one ran, so prefer the exact count when you know it: “called 1 of 2” catches a half-finished test that “at least one” lets through.

Here’s the canonical branchy error test.

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 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 and 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, control falls through to the next line instead of jumping to catch. expect.fail catches that: a non-throwing bug fails the test outright rather than 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 catch inspects the error: its class, then its code field. Asserting on the class and code rather than the message string is the next lesson’s topic; here, notice that’s two assertions, matching the contract on line 2.

1 / 1

The cost is plain: seven lines to assert on two fields of one error. Reach for try/catch plus expect.assertions only when an error has several fields worth checking in one test. To confirm that something rejected with the right type, the one-liner await expect(p).rejects.toThrow(RefundError) does it in a line.

This is the hardest part of the lesson. You already know vi.useFakeTimers(): freeze the clock, advance it with vi.advanceTimersByTime, reset it in beforeEach/afterEach. That machinery is unchanged, but one thing breaks: when the code you advance time through also awaits, the obvious approach quietly fails.

The code under test
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 next line runs before withRetry’s await resumes, and the doWork spy hasn’t been called yet.

The fix is one word, advanceTimersByTimeAsync for advanceTimersByTime, plus the await and async it requires. What matters is why the sync version leaves the promise hanging, because that reasoning generalizes far past fake timers. Scrub through the trace to see it.

Now running setTimeout callback → resolves sleep
Macrotask queue
empty — the timer callback just ran
Microtask queue
withRetry continuation → doWork
doWork ran? no
advanceTimersByTime(5000) fires the setTimeout callback. That callback resolves sleep's promise — but resolving a promise only queues its continuation as a microtask; it doesn't run it. doWork has not run.
Now running expect(doWork).toHaveBeenCalled()
Macrotask queue
empty
Microtask queue
withRetry continuation → doWork still parked — never drained
doWork ran? no — assertion fails
advanceTimersByTime is synchronous, so the test's next line — the assertion — runs now, before the microtask queue is drained. It sees the world one tick too early. This is the failure.
Now running await advanceTimersByTimeAsync(5000)
Macrotask queue
empty
Microtask queue
drained — continuation ran doWork()
doWork ran? yes
Swap in await advanceTimersByTimeAsync. The await yields to the event loop between firing the timer and returning, which drains the microtask queue. The continuation runs, and doWork executes — before the assertion.
timer fires macrotask promise resolves microtask continuation queued microtasks drain doWork runs
advanceTimersByTime stops after the macrotask
advanceTimersByTimeAsync macrotask + drains the microtasks
The whole chain: timer fires (macrotask) → promise resolves → continuation queued (microtask) → microtasks drain → doWork runs → assertion sees the result. The sync timer APIs do only the macrotask step. The *Async variants do the macrotask step and drain the microtasks.

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 from the event-loop lesson, and it gives the rule: any fake-timer test on a code path that awaits should use the *Async variant.

The family of timer methods you’ll reach for:

The *Async timer family
await vi.advanceTimersByTimeAsync(5000); // ↔ advanceTimersByTime — advance by a fixed span
await vi.runOnlyPendingTimersAsync(); // ↔ runOnlyPendingTimers — one round; safe with setInterval
await vi.runAllTimersAsync(); // ↔ runAllTimers — drains every timer; loops forever on a
// self-rescheduling setInterval, so prefer the bounded forms

Sometimes there’s no timer to advance at all: the code schedules a bare microtask with queueMicrotask, chains a .then, or awaits an already-resolved promise. With no clock to tick forward, await Promise.resolve() yields one tick to the event loop so those microtasks drain:

await Promise.resolve(); // yield one tick — pending microtasks drain

Use this sparingly. Sprinkling await Promise.resolve() wherever a test needs something to happen is brittle: you’re guessing at how many ticks the code takes instead of waiting for a real signal. Reach for it only when the code schedules a bare microtask with no promise to await; when there is a promise, such as a return value or a settled effect, await that instead.

Now work through that one-tick gap in plain promises you can run. The Vitest timer APIs can’t execute in this sandbox, but the mechanism is identical: a microtask is queued, and the assertion runs before it drains, until you yield a tick.

A microtask scheduled at module load flips ran to true. readFlag returns ran, but right now it reads 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 microtask flush in plain promises, the same thing advanceTimersByTimeAsync does for you.

    Async failures: rejections, cancellation, and parallel calls

    Section titled “Async failures: rejections, cancellation, and parallel calls”

    A few async assertions cover most failure cases. They handle only the async mechanics; how to assert on an error’s shape, by class, message, or structured code, is the next lesson’s subject.

    The negative form you’ve already seen is the one you’ll reach for most:

    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, since messages get reworded and the test shouldn’t break when they do. When several fields are worth checking at once, use the earlier try/catch plus expect.assertions block; the async version is identical, with the await inside the try.

    Cancellation is a behavior most people forget is theirs to test. If a /lib function takes an AbortSignal, as every async IO function does when cancellation is reachable, the abort path is code you wrote, so it earns an assertion 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);
    });

    The matcher is loose on purpose: a case-insensitive /abort/i rather than an exact class or message. The native AbortError name and message vary across runtimes, so a loose match stays robust while still confirming that something abort-shaped rejected the promise. It’s a reasonable trade here, not a license to be loose elsewhere.

    Last, parallel calls. When a unit fires several async calls at once and you want each outcome independently, so one rejection doesn’t collapse the rest 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',
    ]);
    });

    The test asserts the outcomes the primitive guarantees: two fulfilled, one rejected, none taking down the others.

    One async test’s mess can poison the next. A resource the test opens, an AbortController, an interval, or fake timers, must be released even when an assertion throws partway through and the rest of the test never runs. That is the job of finally and afterEach.

    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. Skip it and the fake clock leaks forward: the next test that expects real time hangs waiting for a clock that no longer ticks, and the failure points at the innocent test instead of the one that forgot to clean up. That run-order bug has one reliable fix: always restore real timers in teardown.

    A long testTimeout is not a fix either. If a test needs thirty seconds 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. Fix the await, not the clock.

    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

    To inspect multiple error fields, pair expect.assertions(n) with try/catch and expect.fail so a skipped branch can’t pass silently.

    Timers on an awaited path

    The *Async variants fire the timer and drain microtasks; the sync ones stop at 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

    Restore real timers in afterEach and release resources in finally, so a thrown assertion can’t leave a mess.

    The one rule underneath them all: the runner can only judge what it awaits. Every technique here makes sure it waits for the thing you care about.