How to reliably test async operations including promises, async/await, timers, and error rejections using Jest and Vitest patterns that prevent false positives and flaky tests.
Mark the test function as async and await the operation. The test runner waits for the promise to settle and fails automatically on rejection.
Use expect(promise).resolves and expect(promise).rejects for expressive inline assertions on promise outcomes. Always await the expression.
Control setTimeout and setInterval with jest.useFakeTimers() or vi.useFakeTimers() to test time-dependent code without real delays.
Forgetting to await or return a promise is the most common async testing bug -- the test passes before assertions actually run, creating a false positive.
Asynchronous code is everywhere in modern JavaScript applications -- API calls, timers, event handlers, and database queries all involve async operations. Testing this code correctly is critical because async tests can pass falsely if the test runner does not wait for the async operation to complete. Understanding the different patterns for handling async in tests is one of the most practical skills an interviewer can assess.
The cleanest way to test async code is marking your test function as async and using await. The test runner waits for the returned promise to settle before moving on. If the awaited promise rejects, the test fails automatically. This mirrors how you write production async code, making tests easy to read.
test('fetches user data', async () => {
const user = await fetchUser(1);
expect(user.name).toBe('Alice');
});If you return a promise from your test function (without async/await), the test runner waits for it. This is less common now but useful when working with older codebases or specific library APIs.
test('fetches user data', () => {
return fetchUser(1).then(user => {
expect(user.name).toBe('Alice');
});
});Before async/await was widespread, tests accepted a done callback parameter. The test runner waits until done() is called. If done is never called, the test times out and fails. Calling done(error) explicitly fails the test. This pattern is error-prone and should be avoided in new code, but you may encounter it in legacy test suites.
Jest and Vitest provide .resolves and .rejects matchers that unwrap promises inline. These are expressive and pair well with await:
await expect(fetchUser(-1)).rejects.toThrow('Not found');
await expect(fetchUser(1)).resolves.toEqual({ name: 'Alice' });Always await the .resolves/.rejects expression. Forgetting await is a common bug -- the test passes without actually checking the assertion because the promise is never awaited.
When code uses setTimeout, setInterval, or Date.now(), you can use fake timers (jest.useFakeTimers() / vi.useFakeTimers()) to control time programmatically. Call jest.advanceTimersByTime(ms) or vi.advanceTimersByTime(ms) to fast-forward without waiting in real time. Always restore real timers in afterEach to prevent test pollution.
The biggest mistake is forgetting to await or return the promise, which causes the test to pass before assertions run. Another pitfall is mixing done with async -- if your test function is async, do not use the done parameter as it leads to confusing timeout errors. Finally, avoid using expect.assertions(n) as a substitute for proper async handling; use it as an additional safety net.
Fun Fact
Before async/await, the 'done' callback pattern was borrowed from Node.js-style error-first callbacks. Mocha was the first popular test runner to support it, and Jest adopted the convention for backwards compatibility.