An async test must return or await its work, otherwise Vitest finishes it before the promise resolves. If an assertion is hidden inside .then with no return and no await, the test ends before the callback runs - and the check simply does not execute. Formally the test is green, in fact it checked nothing. This is the most common cause of a false 'passed' in async code.
The correct form is to return the promise or await it. Vitest understands a returned promise and waits for it, and the .resolves and .rejects matchers make the waiting explicit. Compare two versions of one test: in the brittle one the assertion hangs in .then, in the reliable one the result is awaited via await expect(load()).resolves. The second guarantees the check actually happened.
For errors there is .rejects: await expect(loadUser('missing')).rejects.toThrow('User not found'). The await is mandatory - without it a rejected promise slips past again. To fully close the risk of a check that never ran, add expect.assertions(1): the test fails if exactly one assertion did not run inside - a reliable safeguard for async paths with branching.
A forgotten return or await is the number-one source of a false 'passed'. The test is green because it never reached the assertion, not because the behavior is correct. Such a test catches no regression: break the code and it stays green. So in async tests the eye first looks for whether the test awaits every async operation whose result it checks. A practical trick is, at each expect inside an async callback, to ask yourself whether the test awaited that callback, and if not, return or await it.
A fixed delay via sleep should not be used. It both slows the suite and does not guarantee the absence of a race: it passes on a fast machine and fails on a loaded CI. Wait for the observable result, not for time: resolves/rejects for a promise, findBy and waitFor for the UI, an explicit condition for background work. Waiting by result is deterministic, waiting by timer is not.
When a test has several promises, synchronize them explicitly: await Promise.all for parallel ones or sequential await if order matters. You cannot rely on operations 'making it' on their own - that is the same race, only hidden. Explicit synchronization makes the intent visible and removes the dependency on the environment's speed.
The legacy done callback is better avoided. It swallows errors inside async callbacks and, instead of a clear failure, gives a timeout from which the cause is hard to read. The modern form is an async function with await; it is shorter, reads linearly and correctly propagates errors into the report. done is kept only for the rare event-based API where there is no other way.
The typical failure is a late unhandled rejection: a promise rejected but nobody awaited it, and the error surfaces in another test's report or after the suite finishes. Such a thing is hard to diagnose, because the place of failure does not match the place of the cause. The 'return or await' discipline plus expect.assertions close this class: every async work is awaited where it starts.
// Brittle: assertion in .then, the test ends earlier.
test('loads', () => {
load().then((data) => {
expect(data.ok).toBe(true)
})
})
// Reliable: Vitest waits for the promise.
test('loads', async () => {
await expect(load()).resolves.toMatchObject({ ok: true })
})test('rejects an unknown user', async () => {
expect.assertions(1)
await expect(loadUser('missing')).rejects.toThrow('User not found')
})