Learn Jest in 10 DaysDay 5: Testing Asynchronous Code
books.chapter 5Learn Jest in 10 Days

Day 5: Testing Asynchronous Code

What You'll Learn Today

  • Testing async/await functions
  • Testing Promises with resolves and rejects
  • Testing callback-based code with done
  • Using jest.useFakeTimers() to control setTimeout and setInterval
  • Advancing time with jest.advanceTimersByTime() and jest.runAllTimers()
  • Practical example: testing a debounce function

Why Async Testing Matters

Most real-world JavaScript involves asynchronous operations: API calls, file reads, timers, and event handlers. If your tests don't properly wait for async code to complete, they pass before the assertions even run.

flowchart LR
    subgraph Wrong["Without Proper Async Handling"]
        T1["Test starts"] --> A1["Async operation begins"] --> T2["Test ends βœ“\n(before assertion)"]
        A1 -.-> ASSERT1["Assertion\n(never reached)"]
    end
    subgraph Right["With Proper Async Handling"]
        T3["Test starts"] --> A2["Async operation begins"] --> ASSERT2["Assertion runs"] --> T4["Test ends βœ“"]
    end
    style Wrong fill:#ef4444,color:#fff
    style Right fill:#22c55e,color:#fff

Jest provides three approaches to handle async code:

Approach Best For
async/await Modern async functions
.resolves / .rejects Promise assertions
done callback Legacy callback-based APIs

Testing async/await

The most straightforward approach: mark your test function as async and use await.

// fetchUser.js
async function fetchUser(id) {
  const response = await fetch(`https://api.example.com/users/${id}`);
  if (!response.ok) {
    throw new Error('User not found');
  }
  return response.json();
}

module.exports = { fetchUser };
// fetchUser.test.js
const { fetchUser } = require('./fetchUser');

// Mock the global fetch
global.fetch = jest.fn();

describe('fetchUser', () => {
  afterEach(() => {
    jest.resetAllMocks();
  });

  test('returns user data on success', async () => {
    const mockUser = { id: 1, name: 'Alice' };
    fetch.mockResolvedValue({
      ok: true,
      json: jest.fn().mockResolvedValue(mockUser),
    });

    const user = await fetchUser(1);

    expect(user).toEqual(mockUser);
    expect(fetch).toHaveBeenCalledWith('https://api.example.com/users/1');
  });

  test('throws an error when user is not found', async () => {
    fetch.mockResolvedValue({ ok: false });

    await expect(fetchUser(999)).rejects.toThrow('User not found');
  });
});

TypeScript version:

// fetchUser.ts
export async function fetchUser(id: number): Promise<{ id: number; name: string }> {
  const response = await fetch(`https://api.example.com/users/${id}`);
  if (!response.ok) {
    throw new Error('User not found');
  }
  return response.json();
}
// fetchUser.test.ts
import { fetchUser } from './fetchUser';

const mockFetch = jest.fn();
global.fetch = mockFetch as unknown as typeof fetch;

describe('fetchUser', () => {
  afterEach(() => {
    jest.resetAllMocks();
  });

  test('returns user data on success', async () => {
    const mockUser = { id: 1, name: 'Alice' };
    mockFetch.mockResolvedValue({
      ok: true,
      json: jest.fn().mockResolvedValue(mockUser),
    });

    const user = await fetchUser(1);

    expect(user).toEqual(mockUser);
  });
});

Common mistake: Forgetting await before expect(...).rejects.toThrow(). Without await, the test finishes before the Promise rejects.


Testing Promises with resolves/rejects

Instead of await, you can return the Promise and use .resolves or .rejects matchers.

// multiply.js
function multiplyAsync(a, b) {
  return new Promise((resolve, reject) => {
    if (typeof a !== 'number' || typeof b !== 'number') {
      reject(new Error('Arguments must be numbers'));
    }
    resolve(a * b);
  });
}

module.exports = { multiplyAsync };
// multiply.test.js
const { multiplyAsync } = require('./multiply');

test('resolves with the product of two numbers', () => {
  // IMPORTANT: return the Promise
  return expect(multiplyAsync(3, 4)).resolves.toBe(12);
});

test('rejects when arguments are not numbers', () => {
  return expect(multiplyAsync('a', 2)).rejects.toThrow('Arguments must be numbers');
});

You can also combine with async/await:

test('resolves with the product (async version)', async () => {
  await expect(multiplyAsync(3, 4)).resolves.toBe(12);
});

test('rejects with invalid input (async version)', async () => {
  await expect(multiplyAsync('a', 2)).rejects.toThrow('Arguments must be numbers');
});

TypeScript version:

// multiply.ts
export function multiplyAsync(a: number, b: number): Promise<number> {
  return new Promise((resolve, reject) => {
    if (typeof a !== 'number' || typeof b !== 'number') {
      reject(new Error('Arguments must be numbers'));
    }
    resolve(a * b);
  });
}
Pattern Syntax
Resolved value expect(promise).resolves.toBe(value)
Resolved object expect(promise).resolves.toEqual(obj)
Rejected error expect(promise).rejects.toThrow(message)
Rejected value expect(promise).rejects.toBe(value)

Important: When not using async/await, you must return the Promise from the test. Otherwise Jest won't wait for it.


Testing Callbacks with done

For older callback-based APIs, Jest provides the done parameter. The test will not finish until done() is called.

// readFile.js
function readFile(path, callback) {
  setTimeout(() => {
    if (path === '/error') {
      callback(new Error('File not found'), null);
    } else {
      callback(null, `Contents of ${path}`);
    }
  }, 100);
}

module.exports = { readFile };
// readFile.test.js
const { readFile } = require('./readFile');

test('reads file contents successfully', (done) => {
  readFile('/hello.txt', (err, data) => {
    try {
      expect(err).toBeNull();
      expect(data).toBe('Contents of /hello.txt');
      done();
    } catch (error) {
      done(error);
    }
  });
});

test('returns an error for invalid path', (done) => {
  readFile('/error', (err, data) => {
    try {
      expect(err).toEqual(new Error('File not found'));
      expect(data).toBeNull();
      done();
    } catch (error) {
      done(error);
    }
  });
});
flowchart TB
    subgraph DoneFlow["Callback Testing with done"]
        START["Test starts\n(receives done)"]
        CALL["Call async function"]
        CB["Callback fires"]
        ASSERT["Run assertions"]
        PASS["done() β†’ test passes"]
        FAIL["done(error) β†’ test fails"]
    end
    START --> CALL --> CB --> ASSERT
    ASSERT -->|"All pass"| PASS
    ASSERT -->|"Assertion throws"| FAIL
    style DoneFlow fill:#3b82f6,color:#fff

Important: Always wrap assertions in a try/catch block inside callbacks. If an assertion fails without catching the error, done() is never called and the test times out instead of reporting the real failure.

TypeScript version:

// readFile.ts
type Callback = (err: Error | null, data: string | null) => void;

export function readFile(path: string, callback: Callback): void {
  setTimeout(() => {
    if (path === '/error') {
      callback(new Error('File not found'), null);
    } else {
      callback(null, `Contents of ${path}`);
    }
  }, 100);
}

Comparing Async Testing Approaches

Feature async/await resolves/rejects done callback
Readability Excellent Good Fair
Error handling Natural try/catch Built-in Manual try/catch
Modern APIs Best fit Good fit Not needed
Legacy callbacks Can wrap in Promise Can wrap in Promise Best fit
Forgotten return? Syntax error Silent pass Timeout error
flowchart TB
    Q["Is it a callback-based API?"]
    Q -->|"Yes"| DONE["Use done()"]
    Q -->|"No"| Q2["Need to check\nresolved/rejected value?"]
    Q2 -->|"Simple check"| RES["Use resolves/rejects"]
    Q2 -->|"Complex logic"| ASYNC["Use async/await"]
    style Q fill:#8b5cf6,color:#fff
    style DONE fill:#f59e0b,color:#fff
    style RES fill:#3b82f6,color:#fff
    style ASYNC fill:#22c55e,color:#fff

jest.useFakeTimers() β€” Controlling Time

Functions that use setTimeout, setInterval, or Date.now() are hard to test because they depend on real time. jest.useFakeTimers() replaces these with mock implementations that you control.

// delay.js
function delay(ms) {
  return new Promise((resolve) => {
    setTimeout(resolve, ms);
  });
}

function delayedGreeting(name, callback) {
  setTimeout(() => {
    callback(`Hello, ${name}!`);
  }, 3000);
}

module.exports = { delay, delayedGreeting };
// delay.test.js
const { delay, delayedGreeting } = require('./delay');

describe('delay functions', () => {
  beforeEach(() => {
    jest.useFakeTimers();
  });

  afterEach(() => {
    jest.useRealTimers();
  });

  test('delay resolves after specified time', async () => {
    const promise = delay(5000);

    // fast-forward time by 5 seconds
    jest.advanceTimersByTime(5000);

    await promise; // resolves immediately
  });

  test('delayedGreeting calls callback after 3 seconds', () => {
    const callback = jest.fn();

    delayedGreeting('Alice', callback);

    // callback not called yet
    expect(callback).not.toHaveBeenCalled();

    // fast-forward 3 seconds
    jest.advanceTimersByTime(3000);

    expect(callback).toHaveBeenCalledWith('Hello, Alice!');
  });
});

TypeScript version:

// delay.ts
export function delay(ms: number): Promise<void> {
  return new Promise((resolve) => {
    setTimeout(resolve, ms);
  });
}

export function delayedGreeting(name: string, callback: (msg: string) => void): void {
  setTimeout(() => {
    callback(`Hello, ${name}!`);
  }, 3000);
}

Timer Control Methods

Method Description
jest.useFakeTimers() Replace timer functions with mocks
jest.useRealTimers() Restore real timer functions
jest.advanceTimersByTime(ms) Fast-forward by a specific duration
jest.runAllTimers() Execute all pending timers immediately
jest.runOnlyPendingTimers() Execute only currently pending timers
jest.getTimerCount() Return the number of pending timers
jest.clearAllTimers() Remove all pending timers

jest.advanceTimersByTime() vs jest.runAllTimers()

These two methods serve different purposes.

advanceTimersByTime β€” Step Through Time

Advances the clock by a specific number of milliseconds. Only timers scheduled within that window fire.

test('advanceTimersByTime fires timers at the right time', () => {
  jest.useFakeTimers();

  const first = jest.fn();
  const second = jest.fn();

  setTimeout(first, 1000);
  setTimeout(second, 3000);

  jest.advanceTimersByTime(1500);

  expect(first).toHaveBeenCalled();   // 1000ms has passed
  expect(second).not.toHaveBeenCalled(); // 3000ms hasn't passed yet

  jest.advanceTimersByTime(2000);

  expect(second).toHaveBeenCalled();  // now 3500ms total

  jest.useRealTimers();
});

runAllTimers β€” Execute Everything

Fires all pending timers immediately, regardless of their scheduled time.

test('runAllTimers fires all pending timers', () => {
  jest.useFakeTimers();

  const first = jest.fn();
  const second = jest.fn();
  const third = jest.fn();

  setTimeout(first, 1000);
  setTimeout(second, 5000);
  setTimeout(third, 10000);

  jest.runAllTimers();

  // all fired, regardless of scheduled time
  expect(first).toHaveBeenCalled();
  expect(second).toHaveBeenCalled();
  expect(third).toHaveBeenCalled();

  jest.useRealTimers();
});

runOnlyPendingTimers β€” Avoid Infinite Loops

When timers schedule new timers (like recursive setTimeout), runAllTimers() can loop infinitely. Use runOnlyPendingTimers() to execute only the currently queued timers.

// poll.js
function poll(callback, interval) {
  function tick() {
    callback();
    setTimeout(tick, interval); // schedules itself again
  }
  setTimeout(tick, interval);
}

module.exports = { poll };
// poll.test.js
const { poll } = require('./poll');

test('poll calls callback repeatedly', () => {
  jest.useFakeTimers();
  const callback = jest.fn();

  poll(callback, 1000);

  // first tick
  jest.runOnlyPendingTimers();
  expect(callback).toHaveBeenCalledTimes(1);

  // second tick
  jest.runOnlyPendingTimers();
  expect(callback).toHaveBeenCalledTimes(2);

  // third tick
  jest.runOnlyPendingTimers();
  expect(callback).toHaveBeenCalledTimes(3);

  jest.useRealTimers();
});
flowchart LR
    subgraph Advance["advanceTimersByTime(ms)"]
        A1["Move clock\nby exact amount"]
        A2["Precise control"]
    end
    subgraph RunAll["runAllTimers()"]
        B1["Fire ALL\npending timers"]
        B2["Quick but may\ninfinite loop"]
    end
    subgraph Pending["runOnlyPendingTimers()"]
        C1["Fire CURRENT\ntimers only"]
        C2["Safe for recursive\ntimers"]
    end
    style Advance fill:#3b82f6,color:#fff
    style RunAll fill:#f59e0b,color:#fff
    style Pending fill:#22c55e,color:#fff

Testing setInterval

// counter.js
function startCounter(callback) {
  let count = 0;
  const id = setInterval(() => {
    count++;
    callback(count);
  }, 1000);
  return () => clearInterval(id); // return cleanup function
}

module.exports = { startCounter };
// counter.test.js
const { startCounter } = require('./counter');

describe('startCounter', () => {
  beforeEach(() => {
    jest.useFakeTimers();
  });

  afterEach(() => {
    jest.useRealTimers();
  });

  test('calls callback every second with incrementing count', () => {
    const callback = jest.fn();

    startCounter(callback);

    jest.advanceTimersByTime(3000);

    expect(callback).toHaveBeenCalledTimes(3);
    expect(callback).toHaveBeenNthCalledWith(1, 1);
    expect(callback).toHaveBeenNthCalledWith(2, 2);
    expect(callback).toHaveBeenNthCalledWith(3, 3);
  });

  test('stops counting when cleanup is called', () => {
    const callback = jest.fn();

    const stop = startCounter(callback);

    jest.advanceTimersByTime(2000);
    expect(callback).toHaveBeenCalledTimes(2);

    stop(); // clear the interval

    jest.advanceTimersByTime(3000);
    expect(callback).toHaveBeenCalledTimes(2); // no more calls
  });
});

TypeScript version:

// counter.ts
export function startCounter(callback: (count: number) => void): () => void {
  let count = 0;
  const id = setInterval(() => {
    count++;
    callback(count);
  }, 1000);
  return () => clearInterval(id);
}

Practical Example: Testing a Debounce Function

A debounce function delays execution until a pause in calls. This is a perfect use case for fake timers.

// debounce.js
function debounce(fn, delay) {
  let timeoutId;
  return function (...args) {
    clearTimeout(timeoutId);
    timeoutId = setTimeout(() => {
      fn.apply(this, args);
    }, delay);
  };
}

module.exports = { debounce };

TypeScript version:

// debounce.ts
export function debounce<T extends (...args: unknown[]) => void>(
  fn: T,
  delay: number
): (...args: Parameters<T>) => void {
  let timeoutId: ReturnType<typeof setTimeout>;
  return function (this: unknown, ...args: Parameters<T>) {
    clearTimeout(timeoutId);
    timeoutId = setTimeout(() => {
      fn.apply(this, args);
    }, delay);
  };
}
sequenceDiagram
    participant User
    participant Debounced as Debounced Function
    participant Timer
    participant Original as Original Function

    User->>Debounced: call()
    Debounced->>Timer: setTimeout(300ms)
    Note over Timer: 100ms passes...
    User->>Debounced: call() again
    Debounced->>Timer: clearTimeout + setTimeout(300ms)
    Note over Timer: 100ms passes...
    User->>Debounced: call() again
    Debounced->>Timer: clearTimeout + setTimeout(300ms)
    Note over Timer: 300ms passes...
    Timer->>Original: execute()
// debounce.test.js
const { debounce } = require('./debounce');

describe('debounce', () => {
  beforeEach(() => {
    jest.useFakeTimers();
  });

  afterEach(() => {
    jest.useRealTimers();
  });

  test('calls the function after the delay', () => {
    const fn = jest.fn();
    const debounced = debounce(fn, 300);

    debounced();

    // not called immediately
    expect(fn).not.toHaveBeenCalled();

    // fast-forward past the delay
    jest.advanceTimersByTime(300);

    expect(fn).toHaveBeenCalledTimes(1);
  });

  test('resets the delay on rapid calls', () => {
    const fn = jest.fn();
    const debounced = debounce(fn, 300);

    // rapid calls
    debounced();
    jest.advanceTimersByTime(100);
    debounced();
    jest.advanceTimersByTime(100);
    debounced();
    jest.advanceTimersByTime(100);

    // 300ms hasn't passed since the LAST call
    expect(fn).not.toHaveBeenCalled();

    // wait for the full delay
    jest.advanceTimersByTime(200);

    expect(fn).toHaveBeenCalledTimes(1);
  });

  test('passes arguments to the original function', () => {
    const fn = jest.fn();
    const debounced = debounce(fn, 300);

    debounced('hello', 42);

    jest.advanceTimersByTime(300);

    expect(fn).toHaveBeenCalledWith('hello', 42);
  });

  test('only calls once for burst of calls', () => {
    const fn = jest.fn();
    const debounced = debounce(fn, 500);

    // simulate rapid typing
    for (let i = 0; i < 10; i++) {
      debounced(`keystroke-${i}`);
      jest.advanceTimersByTime(100);
    }

    // still not called (last call was 100ms ago, need 500ms)
    expect(fn).not.toHaveBeenCalled();

    // wait for the remaining delay
    jest.advanceTimersByTime(400);

    expect(fn).toHaveBeenCalledTimes(1);
    expect(fn).toHaveBeenCalledWith('keystroke-9'); // last argument wins
  });

  test('allows separate calls after the delay passes', () => {
    const fn = jest.fn();
    const debounced = debounce(fn, 300);

    // first call
    debounced('first');
    jest.advanceTimersByTime(300);
    expect(fn).toHaveBeenCalledTimes(1);
    expect(fn).toHaveBeenCalledWith('first');

    // second call after delay
    debounced('second');
    jest.advanceTimersByTime(300);
    expect(fn).toHaveBeenCalledTimes(2);
    expect(fn).toHaveBeenLastCalledWith('second');
  });
});

Real-World Use Case: Search Input

// searchInput.js
const api = require('./api');

function setupSearch(inputElement) {
  const debouncedSearch = debounce(async (query) => {
    const results = await api.search(query);
    displayResults(results);
  }, 300);

  inputElement.addEventListener('input', (e) => {
    debouncedSearch(e.target.value);
  });
}
// searchInput.test.js
const api = require('./api');
const { debounce } = require('./debounce');

jest.mock('./api');

test('search triggers after user stops typing', () => {
  jest.useFakeTimers();
  const search = jest.fn();
  const debouncedSearch = debounce(search, 300);

  // user types "jest"
  debouncedSearch('j');
  jest.advanceTimersByTime(50);
  debouncedSearch('je');
  jest.advanceTimersByTime(50);
  debouncedSearch('jes');
  jest.advanceTimersByTime(50);
  debouncedSearch('jest');

  // API not called during typing
  expect(search).not.toHaveBeenCalled();

  // user pauses
  jest.advanceTimersByTime(300);

  // now search fires with the final query
  expect(search).toHaveBeenCalledTimes(1);
  expect(search).toHaveBeenCalledWith('jest');

  jest.useRealTimers();
});

Common Pitfalls

1. Forgetting to Return or Await

// BAD: this test always passes
test('broken test', () => {
  expect(Promise.reject(new Error('oops'))).rejects.toThrow(); // no return or await!
});

// GOOD: properly awaited
test('correct test', async () => {
  await expect(Promise.reject(new Error('oops'))).rejects.toThrow();
});

2. Mixing Fake and Real Timers

// BAD: using real async code with fake timers
test('this will hang', async () => {
  jest.useFakeTimers();

  // real async code that internally uses setTimeout won't resolve
  await someRealAsyncFunction(); // hangs forever!

  jest.useRealTimers();
});

3. Not Cleaning Up Timers

// Always restore timers to avoid affecting other tests
afterEach(() => {
  jest.useRealTimers();
});

4. Using expect.assertions() for Safety

When testing async code, use expect.assertions(n) to ensure all assertions actually ran.

test('handles rejection', async () => {
  expect.assertions(1); // ensures exactly 1 assertion runs

  try {
    await fetchUser(999);
  } catch (e) {
    expect(e.message).toBe('User not found');
  }
});

Summary

Concept Description
async/await Mark test as async, await the result
.resolves Assert that a Promise resolves to a value
.rejects Assert that a Promise rejects with an error
done callback Signal test completion for callback-based code
jest.useFakeTimers() Replace timer functions with controllable mocks
jest.advanceTimersByTime(ms) Fast-forward the clock by a specific duration
jest.runAllTimers() Execute all pending timers immediately
jest.runOnlyPendingTimers() Execute only currently queued timers
expect.assertions(n) Verify that n assertions were called

Key Takeaways

  1. Always return or await Promises in tests
  2. Use done only for legacy callback APIs; prefer async/await for new code
  3. Fake timers give you deterministic control over time-dependent code
  4. Use advanceTimersByTime() for precise time control, runAllTimers() for quick execution
  5. Always restore real timers in afterEach to prevent test pollution

Exercises

Exercise 1: Basics

Test the following async function using async/await and .resolves:

function divide(a, b) {
  return new Promise((resolve, reject) => {
    if (b === 0) {
      reject(new Error('Cannot divide by zero'));
    }
    resolve(a / b);
  });
}

Write tests for:

  • Successful division (e.g., 10 / 2 = 5)
  • Division by zero throws an error

Exercise 2: Intermediate

Test the following retry function using fake timers:

function retry(fn, retries, delay) {
  return new Promise((resolve, reject) => {
    function attempt(remaining) {
      fn()
        .then(resolve)
        .catch((err) => {
          if (remaining <= 0) {
            reject(err);
          } else {
            setTimeout(() => attempt(remaining - 1), delay);
          }
        });
    }
    attempt(retries);
  });
}

Write tests for:

  • Resolves immediately when fn succeeds on the first attempt
  • Retries and succeeds on the second attempt
  • Rejects after all retries are exhausted

Challenge

Implement and test a throttle function. Unlike debounce, throttle executes the function immediately and then ignores subsequent calls for a cooldown period.

function throttle(fn, cooldown) {
  let isThrottled = false;
  return function (...args) {
    if (isThrottled) return;
    fn.apply(this, args);
    isThrottled = true;
    setTimeout(() => {
      isThrottled = false;
    }, cooldown);
  };
}

Write tests verifying:

  • The function executes immediately on first call
  • Subsequent calls within the cooldown are ignored
  • The function can be called again after the cooldown

References


Next up: In Day 6, we'll learn about "Testing React Components." You'll explore how to render React components in tests, simulate user interactions, and write effective component tests with React Testing Library!