Day 5: Testing Asynchronous Code
What You'll Learn Today
- Testing async/await functions
- Testing Promises with
resolvesandrejects - Testing callback-based code with
done - Using
jest.useFakeTimers()to controlsetTimeoutandsetInterval - Advancing time with
jest.advanceTimersByTime()andjest.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
awaitbeforeexpect(...).rejects.toThrow(). Withoutawait, 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/catchblock 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
- Always
returnorawaitPromises in tests - Use
doneonly for legacy callback APIs; preferasync/awaitfor new code - Fake timers give you deterministic control over time-dependent code
- Use
advanceTimersByTime()for precise time control,runAllTimers()for quick execution - Always restore real timers in
afterEachto 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
fnsucceeds 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
- Jest - Testing Asynchronous Code
- Jest - Timer Mocks
- Jest - expect.resolves
- Jest - expect.rejects
- MDN - setTimeout
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!