What makes async code different to test?
bookRepair() doesn't return a booking. It returns a promise of one, because it has to ask the calendar first. When the test calls it, the answer isn't ready yet. If the test checks something and ends before the promise settles, the check either sees the wrong thing or never runs at all.
The runner can only wait for what you give it. When a test function returns a promise, Vitest and Jest wait for that promise before deciding the result. An async test function always returns a promise, so writing async and using await inside is all it takes to make the runner wait. The rest of this page is about the ways that chain gets broken.
Why does the starting test pass when it shouldn't?
Look closely at the starting test: expect(bookRepair(...)).resolves.toEqual(...) with no await in front and no async on the test. The .resolves assertion returns a promise of its own, and nobody waits for it. The test function returns undefined straight away, so the runner records a pass before the booking has even started.
If the code returned status: 'pending', the assertion would fail a moment later, after the result was already reported. In the practice runner that failure is lost entirely. Real Vitest 3 prints a warning that an assertion was not awaited, and Jest may report an error in a later test or after the run. Either way the test you were looking at stays green. Treat any .resolves or .rejects without await as a bug.
The fix is two words: async on the test, await before expect. Check my work proves it by running your test against a copy that returns the wrong status. An awaited test catches it; the starting test doesn't.
test('books a repair on a day with free slots', async () => {
const calendar = { slotsOn: async () => 3, reserve: vi.fn().mockResolvedValue(undefined) };
await expect(bookRepair('2026-03-20', calendar))
.resolves.toEqual({ date: '2026-03-20', status: 'booked' });
});

Should you use await or .resolves?
Both are fine, and they read differently. const booking = await bookRepair(...) followed by ordinary expect(booking.status).toBe('booked') is easiest to follow, especially when you check several fields or call more code afterwards. await expect(promise).resolves.toEqual(...) is compact when one check is all you need.
For rejections, .rejects is usually shorter than try and catch. In Vitest and Jest the usual form is await expect(bookRepair(day, fullCalendar)).rejects.toThrow('No repair slots'), which fails if the promise resolves, fails if it rejects with a different message, and passes only for the right refusal. The practice runner accepts that form too. The demo checks the rejected error directly with .rejects.toHaveProperty('message', ...), which pins the whole message and works the same way everywhere.
test('refuses a day with no free slots', async () => {
const calendar = { slotsOn: vi.fn().mockResolvedValue(0), reserve: vi.fn() };
await expect(bookRepair('2026-03-21', calendar)).rejects.toHaveProperty('message', 'No repair slots on 2026-03-21');
expect(calendar.reserve).not.toHaveBeenCalled();
});How do you fake an async dependency?
The calendar in a real shop lives on a server. In a test you hand bookRepair() an object with the same two methods. An async arrow such as async () => 3 works, and vi.fn().mockResolvedValue(3) does the same while also recording calls. mockRejectedValue(new Error('Calendar offline')) makes the promise fail, which is how you test the code's error path without breaking a real server.
Use the Once versions for a sequence: mockResolvedValueOnce(2).mockResolvedValueOnce(0) answers 2 the first time and 0 the second, which suits code that checks availability twice. A mock with no value set returns undefined, not a promise, and await undefined is fine, so a plain vi.fn() works for methods whose answer the code ignores.
What does expect.assertions() protect against?
A test with an expect inside catch only checks something when the code throws. If the code stops throwing, the catch block is skipped, no assertion runs, and the test passes. That is the first trap in the Break It challenge: cancelling an unknown booking returns null, and the test happily agrees.
expect.assertions(1) at the top of the test tells the runner to count assertions and fail the test if the count is different. expect.hasAssertions() asks for at least one. They are the safety net for any test whose assertions sit inside a callback, a catch or a .then(). With .rejects you rarely need them, because the assertion is always reached.
test('cancelling an unknown booking fails', async () => {
expect.assertions(1);
try {
await cancelBooking(99, bookings);
} catch (e) {
expect(e.message).toMatch('No booking 99');
}
});What happens when a promise never settles?
The test waits until its timeout and then fails with a message about timing out. Vitest's default is 5 seconds; the practice runner gives up after 3 and says whether a timer or a promise is the likely cause. The usual reasons are a mock that returns a promise that never resolves, such as new Promise(() => {}), or code waiting on a real setTimeout while fake timers are on.
Read a timeout as "something never finished", not "the code is slow". Raising the timeout almost never helps. Find the promise the test is stuck on: log before and after each await and see which line never prints in the Output view.
What about callbacks and done?
Older code takes a callback instead of returning a promise. Jest lets a test accept a done argument and wait until you call it, and the practice runner supports that too. Vitest doesn't: a test that asks for done fails with an error telling you to return a promise. Wrapping the callback in a promise is good advice for both runners: a forgotten done() turns into a timeout, and an assertion that throws before done() can report the wrong error.
Wrap once and the rest of the test reads like any other async test: const result = await new Promise((resolve) => loadHire(id, resolve));.
What do AI-written async tests get wrong?
Missing await is the classic, and it is hard to spot because the test looks complete. Search generated tests for .resolves, .rejects and .then( and check each one is awaited or returned. The second is the try and catch pattern with no expect.assertions(), which passes whenever the error stops happening. The third is mocks that resolve values the real service never returns, such as { ok: true } where the real calendar answers a number.
A quick way to test a test is the one Check my work uses. Break the code on purpose, for example make it return the wrong status, and see whether the test goes red. If it stays green, the test is not waiting for the answer.
- Every async test is
asyncand awaits its promises .resolvesand.rejectsalways haveawaitin front- Assertions in
catchor callbacks come withexpect.assertions(n) - Mocks resolve the same shape the real dependency returns
Field notes
- A test only waits for a promise when you return it or use
await. - A
.resolvesor.rejectscheck withoutawaitcan pass before anything is checked. - Use
.rejectswith the error message to prove the right refusal happened. expect.assertions(1)fails a test whose only check sits in acatchthat never ran.
Easy to get wrong
Without await, the test ends before the promise settles, so it passes even when the result is wrong.
If nothing throws, the assertion inside catch never runs and the test passes. .rejects always asserts.
A bare toThrow() passes for any error, including a TypeError from a broken mock. Name the error you expect.
Questions people ask
Can I `await` inside `beforeEach`?
Yes. Make the hook async and the runner waits for it before each test, which suits setup that has to load something first.
Does `.rejects.toThrow()` work with a promise that rejects with a plain string?
toThrow() expects an error object. If code rejects with a string, use .rejects.toBe('No slots') instead, and consider changing the code to reject with new Error(...), which carries a stack trace.
Why does my test pass in the runner but warn in real Vitest?
Vitest 3 warns about .resolves and .rejects assertions that were not awaited. The practice runner doesn't show that warning, so an unawaited assertion here passes silently. Add the await and both agree.
Can I run async tests at the same time to save time?
Vitest has test.concurrent, and the practice runner accepts the name but runs tests one after another. Concurrent tests must not share any state, so start with ordinary tests and only reach for it when a suite is slow.