Testing Async JavaScript: Promises, async/await, and Fake Timers in Jest/Vitest

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 dashboard

This catches issues like missing loading states, wrong success messages, or redirect failures that mocked tests never see.

Key Takeaways

  • Always await in async tests — returning a promise without await hides failures
  • Use jest.useFakeTimers() + jest.advanceTimersByTime() to test debounce, polling, and retries without real delays
  • In Vitest, use advanceTimersByTimeAsync when timer callbacks contain await — 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

Read more