Stop Sleeping in Vitest Tests: Use expect.poll for Eventually True Assertions
Replace fixed sleeps in Vitest with expect.poll to assert eventually true conditions. Learn the mechanics, a worked example, trade-offs, and verification steps for stable
19 Jul 2025, 01:28 UTC

Tests that pass locally and fail in CI after an async action are usually asserting too early or hiding the timing with a fixed sleep. The useful takeaway is to stop guessing how long to wait and let the test wait for the condition. In Vitest that means using expect.poll to express “eventually true” instead of await new Promise(r => setTimeout(r, 500)).
The flaky sleep pattern
A fixed sleep makes a test brittle. Too short and it flakes when the machine is slow. Too long and every run pays the cost even when the state arrives quickly. Sleeps also give no signal about what the actual value was when the test gave up.
expect.poll is Vitest’s built-in polling assertion. It repeatedly evaluates a function and applies a matcher until the matcher passes or a timeout expires. The function should be pure and cheap: read state, do not dispatch actions or mutate mocks.
How expect.poll works
The call shape is await expect.poll(fn, options).toBe(expected). fn can be async and is invoked on each interval. Options commonly include timeout for the total budget and interval for the pause between attempts.
On timeout Vitest reports the last matcher error, which usually shows the final observed value rather than only the first attempt. That makes diagnosis easier than a generic “expected X received Y” from an immediate assertion.
Option names, defaults, and matcher support are version-sensitive. Check the Vitest major in package.json or the lockfile before codifying timeouts in a shared helper.
Worked example: waiting for a repository status
Assume a module exposes repo.getStatus() that returns a promise resolving to an object with a state field. After triggering an update you want the state to become 'ready'.
import { describe, it, expect } from 'vitest';
import { repo } from './repository';
describe('repository status', () => {
it('should eventually reach the ready state', async () => {
await repo.triggerUpdate();
// Instead of a sleep, we poll the status method
await expect.poll(async () => {
const status = await repo.getStatus();
return status.state;
}, {
timeout: 2000,
interval: 50
}).toBe('ready');
});
});
Trade-offs and limitations
Polling can hide slow UI or I/O by making the suite wait longer, and it does not repair missing await, unhandled rejections, or race conditions. Prefer deterministic events or awaited promises; use polling only at genuine async boundaries such as third-party widgets or eventually consistent caches.
With vi.useFakeTimers, polling may not advance as expected unless timers are advanced; prefer real timers for eventual assertions or verify behavior explicitly. Do not put side effects in the polled callback; repeated evaluation can double-submit or inflate mock call counts.
Practical verification
To ensure your implementation is stable, create a temporary test that resolves after a delay and one that never resolves. Run npx vitest run <file> --reporter=verbose to confirm pass/fail messages and last-value reporting.
Run the same scenario in watch mode and vitest run, with retries disabled, to ensure the assertion is stable rather than saved by retry. Measure suite duration before and after replacing sleeps; if total time rises sharply, tighten timeouts or fix the underlying async boundary.
Standardize a tiny project helper around expect.poll, with one timeout budget for local runs and a larger CI budget via config. Ban bare setTimeout sleeps in tests through review or lint rules.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.