Testing Async JavaScript: Promises, async/await, and Fake Timers in Jest/Vitest
Async JavaScript is everywhere — fetch calls, timers, event handlers, database queries. Testing async code correctly requires understanding how the JavaScript event loop works and how Jest and Vitest provide tools to control it.
Async Test Functions
Both Jest and Vitest support async/await in test functions natively:
// Jest or Vitest
test("fetches user data", async () => {
const user = await fetchUser(1);
expect(user.name).toBe("alice");
});Never return a promise without await — the test will pass even if the assertion fails:
// BAD — test always passes, assertion never runs
test("broken async test", () => {
return fetchUser(1).then((user) => {
expect(user.name).toBe("wrong name"); // never throws
});
});
// GOOD
test("correct async test", async () => {
const user = await fetchUser(1);
expect(user.name).toBe("alice");
});Testing Rejected Promises
Use rejects to assert a promise rejects:
test("throws on invalid ID", async () => {
await expect(fetchUser(-1)).rejects.toThrow("Invalid user ID");
});
// Or with try/catch
test("throws on invalid ID", async () => {
try {
await fetchUser(-1);
fail("should have thrown"); // unreachable
} catch (error) {
expect(error.message).toBe("Invalid user ID");
}
});Mocking fetch
The global fetch is the most common async function to mock:
// Jest
global.fetch = jest.fn();
// Vitest
import { vi } from "vitest";
global.fetch = vi.fn();Return a mock response:
beforeEach(() => {
global.fetch = jest.fn().mockResolvedValue({
ok: true,
status: 200,
json: () => Promise.resolve({ id: 1, name: "alice" }),
});
});
afterEach(() => {
jest.restoreAllMocks();
});
test("fetches and parses user", async () => {
const user = await getUser(1);
expect(fetch).toHaveBeenCalledWith("/api/users/1");
expect(user.name).toBe("alice");
});Simulate an error:
test("handles network error", async () => {
global.fetch = jest.fn().mockRejectedValue(new Error("Network error"));
await expect(getUser(1)).rejects.toThrow("Network error");
});Fake Timers
jest.useFakeTimers() replaces setTimeout, setInterval, Date, and other timer APIs with fake implementations you control:
beforeEach(() => {
jest.useFakeTimers();
});
afterEach(() => {
jest.useRealTimers();
});
test("debounced function calls once after delay", () => {
const fn = jest.fn();
const debounced = debounce(fn, 300);
debounced();
debounced();
debounced();
expect(fn).not.toHaveBeenCalled(); // called 3 times but debounced
jest.advanceTimersByTime(300);
expect(fn).toHaveBeenCalledTimes(1); // only called once after delay
});jest.runAllTimers() vs jest.advanceTimersByTime()
// Run all pending timers immediately
jest.runAllTimers();
// Advance time by a specific amount (triggers only timers within that window)
jest.advanceTimersByTime(1000);
// Advance through all pending timers recursively (handles timers that schedule more timers)
jest.runAllTimersAsync();Vitest Fake Timers
import { vi } from "vitest";
beforeEach(() => {
vi.useFakeTimers();
});
afterEach(() => {
vi.useRealTimers();
});
test("polling retries until success", async () => {
let attempts = 0;
const fetch = vi.fn().mockImplementation(() => {
attempts++;
if (attempts < 3) return Promise.resolve({ status: "pending" });
return Promise.resolve({ status: "done" });
});
const resultPromise = pollUntilDone(fetch, { interval: 1000 });
// Advance through retries
await vi.advanceTimersByTimeAsync(1000);
await vi.advanceTimersByTimeAsync(1000);
const result = await resultPromise;
expect(result.status).toBe("done");
expect(fetch).toHaveBeenCalledTimes(3);
});Note advanceTimersByTimeAsync — in Vitest, this also flushes microtasks (resolved promises), which is essential when your timer callbacks do await.
Testing setInterval
test("interval fires at correct rate", () => {
jest.useFakeTimers();
const callback = jest.fn();
setInterval(callback, 1000);
jest.advanceTimersByTime(3500);
expect(callback).toHaveBeenCalledTimes(3); // fires at 1000, 2000, 3000
jest.useRealTimers();
});Flushing Promises
Sometimes you need to flush all pending promises without advancing time:
// Helper function
const flushPromises = () => new Promise((resolve) => setImmediate(resolve));
test("handles async state update", async () => {
const { result } = renderHook(() => useAsyncData());
// Trigger async operation
act(() => {
result.current.fetch();
});
await flushPromises();
expect(result.current.data).toBeDefined();
expect(result.current.loading).toBe(false);
});In Vitest:
import { flushPromises } from "@vue/test-utils";
// or
await vi.runAllMicrotasks();Testing Event Emitters
import EventEmitter from "events";
test("emits event after async operation", async () => {
const emitter = new EventEmitter();
const received = [];
emitter.on("data", (value) => received.push(value));
await processAndEmit(emitter, [1, 2, 3]);
expect(received).toEqual([1, 2, 3]);
});For a single event, use a promise wrapper:
function waitForEvent(emitter, event) {
return new Promise((resolve) => emitter.once(event, resolve));
}
test("emits done event", async () => {
const emitter = new EventEmitter();
startLongProcess(emitter);
const result = await waitForEvent(emitter, "done");
expect(result.status).toBe("success");
});Concurrent Async Operations
Test that your code handles parallel operations correctly:
test("fetches all users concurrently", async () => {
const fetchCalls = [];
global.fetch = jest.fn().mockImplementation((url) => {
fetchCalls.push(url);
return Promise.resolve({
ok: true,
json: () => Promise.resolve({ url }),
});
});
const results = await fetchAllUsers([1, 2, 3]);
// All three should have been called
expect(fetch).toHaveBeenCalledTimes(3);
expect(results).toHaveLength(3);
});Test race conditions using controlled delays:
test("uses fastest response", async () => {
const fast = jest.fn().mockResolvedValue("fast result");
const slow = jest.fn().mockImplementation(
() => new Promise((resolve) => setTimeout(() => resolve("slow result"), 1000))
);
jest.useFakeTimers();
const resultPromise = Promise.race([fast(), slow()]);
const result = await resultPromise;
expect(result).toBe("fast result");
jest.useRealTimers();
});Testing Async React Hooks
import { renderHook, act, waitFor } from "@testing-library/react";
test("useUser fetches and returns user data", async () => {
global.fetch = jest.fn().mockResolvedValue({
ok: true,
json: () => Promise.resolve({ id: 1, name: "alice" }),
});
const { result } = renderHook(() => useUser(1));
// Initially loading
expect(result.current.loading).toBe(true);
// Wait for async state to settle
await waitFor(() => {
expect(result.current.loading).toBe(false);
});
expect(result.current.user.name).toBe("alice");
});Testing AbortController
test("cancels fetch on abort", async () => {
const controller = new AbortController();
let aborted = false;
global.fetch = jest.fn().mockImplementation((url, { signal }) => {
return new Promise((_, reject) => {
signal.addEventListener("abort", () => {
aborted = true;
reject(new DOMException("Aborted", "AbortError"));
});
});
});
const fetchPromise = fetchWithAbort("/api/data", controller.signal);
controller.abort();
await expect(fetchPromise).rejects.toThrow("Aborted");
expect(aborted).toBe(true);
});Common Async Testing Mistakes
Not awaiting assertions:
// BAD — test passes even if the assertion fails
test("broken", () => {
expect(asyncFunction()).resolves.toBe("value"); // returns a promise, not awaited
});
// GOOD
test("correct", async () => {
await expect(asyncFunction()).resolves.toBe("value");
});Not cleaning up fake timers:
// BAD — fake timers leak into other tests
test("uses fake timers", () => {
jest.useFakeTimers();
// ... test ...
// forgot jest.useRealTimers()
});Always restore in afterEach.
Mixing fake timers with real async:
Fake timers control setTimeout but not Promise microtasks. If your async code uses both, use jest.runAllTimersAsync() or await flushPromises() after advancing time.
End-to-End Async Testing
Unit tests with mocked fetch verify your code's async logic. End-to-end tests verify that the actual API responds correctly, with real network calls and real timing. HelpMeTest runs browser-level end-to-end tests:
Scenario: form submission with async validation
Given a user fills out the registration form
When they submit
Then a loading spinner appears immediately
And within 3 seconds the success message is shown
And the user is redirected to the dashboardThis catches issues like missing loading states, wrong success messages, or redirect failures that mocked tests never see.
Key Takeaways
- Always
awaitin async tests — returning a promise withoutawaithides failures - Use
jest.useFakeTimers()+jest.advanceTimersByTime()to test debounce, polling, and retries without real delays - In Vitest, use
advanceTimersByTimeAsyncwhen timer callbacks containawait— it flushes microtasks await expect(promise).rejects.toThrow()is the cleanest way to test rejected promises- Restore real timers in
afterEach— leaked fake timers cause mysterious failures in other tests