All topics
Testingintermediate

Testing Asynchronous Code

Techniques and common pitfalls for correctly testing promise-based and callback-based asynchronous code, including fake timers.

Asynchronous tests fail in a specific way: they pass without having tested anything.

Testing async code without awaiting it properly is like a teacher collecting exam papers and grading the class immediately, without actually waiting for every student to finish writing — a few unfinished papers might get graded as blank or fail silently, even though the student was still legitimately in the middle of writing a correct answer when the papers were prematurely collected.

Key Concepts

1
    // broken -- the test ends before the assertion runs
    it('loads the user', () => {
      fetchUser(1).then(u => expect(u.name).toBe('Asha'));
    });
2
Nothing is returned or awaited, so the test finishes immediately and passes. A wrong name never fails it.
3
The three correct forms.
    it('loads the user', async () => {
      const u = await fetchUser(1);
      expect(u.name).toBe('Asha');
    });
4
    it('loads the user', () =>
      expect(fetchUser(1)).resolves.toMatchObject({ name: 'Asha' }));
5
    it('rejects for a missing user', () =>
      expect(fetchUser(999)).rejects.toThrow('not found'));
6
Return or await. That is the rule — a promise the test does not wait for is a promise the test does not test.
7
expect.assertions guards the error path.
    it('handles failure', async () => {
      expect.assertions(1);          // exactly one must run
      try {
        await failing();
      } catch (e) {
        expect(e.message).toBe('boom');
      }
    });
expect.assertions
8
Without it, a function that stops throwing makes the catch never run — and the test passes.
catch
9
Fake timers remove real waiting.
    jest.useFakeTimers();
    const fn = jest.fn();
    setTimeout(fn, 5000);
    jest.advanceTimersByTime(5000);
    expect(fn).toHaveBeenCalled();
    jest.useRealTimers();
10
A test with a real five-second wait is a test people skip.
11
Mixing fake timers and promises needs care. Advancing timers does not flush the microtask queue:
12
    jest.advanceTimersByTime(1000);
    await Promise.resolve();          // let pending promises settle
13
Or jest.runAllTimersAsync() in recent versions.
jest.runAllTimersAsync()
14
For UI, wait for the result rather than a duration.
    await screen.findByText('Loaded');          // retries until found
    await waitFor(() => expect(fn).toHaveBeenCalled());
15
    await new Promise(r => setTimeout(r, 1000)); // flaky -- avoid
16
A fixed delay is either too short on a slow machine or wasted time on a fast one. That is the main source of flaky tests in CI.
17
What the interviewer is probing.1. "What is the most common async test bug?" Probing: the missing await. Stalls: "Timeouts." Moves up: forgetting to return or await the promise, so the test finishes before the assertion runs and passes regardless.
18
2. "How do you assert that a promise rejects?" Probing: the API. Stalls: "try/catch." *Moves up:* await expect(fn()).rejects.toThrow(...), which fails if it resolves instead.
19
3. "How do you test a debounce without waiting?" Probing: fake timers. Stalls: "Sleep in the test." Moves up: fake timers, advancing virtual time so a 300ms debounce is tested instantly.
20
4. "Why avoid arbitrary sleeps?" Probing: flakiness. Stalls: "They are simple." Moves up: they are slow and still race — wait for the condition instead, with a retrying assertion.