The Challenge of Async Testing
Asynchronous code is everywhere in modern JavaScript - API calls, database queries, timers, file operations, and event handlers all operate asynchronously. Testing async code presents unique challenges because tests need to wait for operations to complete, handle both success and failure scenarios, and deal with race conditions. Without proper techniques, async tests can be flaky, slow, or simply incorrect.
The key challenge is that tests are synchronous by default - they run from top to bottom and finish immediately. But async operations complete at some point in the future. We need mechanisms to tell our testing framework: "wait for this async operation to complete before checking assertions." Different testing frameworks provide different APIs for this, but the core concepts remain the same.
Common Async Patterns:
- Callbacks: Functions passed as arguments to be called later
- Promises: Objects representing eventual completion or failure
- Async/Await: Syntactic sugar making async code look synchronous
- Timers: setTimeout, setInterval for delayed execution
- Events: Event emitters and listeners
Testing Callbacks
The Done Callback Pattern
Jest and other frameworks provide a done callback parameter to signal when an async test completes:
// Function that uses callback
function fetchData(callback) {
setTimeout(() => {
callback({ data: 'peanut butter' });
}, 100);
}
// Test with done callback
test('fetches data with callback', (done) => {
function callback(data) {
try {
expect(data).toEqual({ data: 'peanut butter' });
done(); // Signal test completion
} catch (error) {
done(error); // Signal test failure
}
}
fetchData(callback);
});
Don't Forget done():
If you don't call done(), the test will complete immediately and pass even if your assertions never run! Always call done() - either to signal success or pass an error for failure.
Testing Error Callbacks
function fetchDataWithError(callback) {
setTimeout(() => {
callback(new Error('Network error'));
}, 100);
}
test('handles errors in callback', (done) => {
function callback(error) {
try {
expect(error).toBeInstanceOf(Error);
expect(error.message).toBe('Network error');
done();
} catch (err) {
done(err);
}
}
fetchDataWithError(callback);
});
Testing Promises
Returning Promises
The simplest way to test promises is to return the promise from your test. Jest will wait for it to resolve:
function fetchUser(id) {
return Promise.resolve({ id, name: 'John' });
}
test('fetches user by ID', () => {
// Return the promise - Jest waits for it
return fetchUser(1).then(user => {
expect(user).toEqual({ id: 1, name: 'John' });
});
});
Must Return the Promise:
If you forget to return the promise, the test completes immediately without waiting. Always return promises in tests unless using async/await.
Testing Promise Rejections
function fetchUserWithError(id) {
return Promise.reject(new Error('User not found'));
}
// Method 1: Using .catch()
test('handles user not found error', () => {
return fetchUserWithError(1).catch(error => {
expect(error.message).toBe('User not found');
});
});
// Method 2: Using expect().rejects
test('handles user not found error', () => {
return expect(fetchUserWithError(1))
.rejects
.toThrow('User not found');
});
// Method 3: Using assertions count
test('handles user not found error', () => {
expect.assertions(1); // Ensure assertion runs
return fetchUserWithError(1).catch(error => {
expect(error.message).toBe('User not found');
});
});
Testing Promise Resolution
// Testing resolved value
test('resolves with user data', () => {
return expect(fetchUser(1))
.resolves
.toEqual({ id: 1, name: 'John' });
});
// Chaining matchers
test('user has correct properties', () => {
return expect(fetchUser(1))
.resolves
.toMatchObject({ id: 1 });
});
Testing with Async/Await
Basic Async/Await Tests
Async/await makes async tests look like synchronous code, greatly improving readability:
async function fetchUser(id) {
const response = await fetch(`/api/users/${id}`);
return response.json();
}
test('fetches user with async/await', async () => {
const user = await fetchUser(1);
expect(user).toEqual({ id: 1, name: 'John' });
});
// Multiple async operations
test('fetches multiple users', async () => {
const user1 = await fetchUser(1);
const user2 = await fetchUser(2);
expect(user1.id).toBe(1);
expect(user2.id).toBe(2);
});
Testing Async Errors
async function fetchUserWithError(id) {
throw new Error('User not found');
}
// Method 1: Using try/catch
test('handles async errors', async () => {
try {
await fetchUserWithError(1);
// If we get here, test should fail
expect(true).toBe(false);
} catch (error) {
expect(error.message).toBe('User not found');
}
});
// Method 2: Using expect().rejects (cleaner)
test('handles async errors', async () => {
await expect(fetchUserWithError(1))
.rejects
.toThrow('User not found');
});
// Testing specific error types
test('throws TypeError', async () => {
await expect(invalidOperation())
.rejects
.toThrow(TypeError);
});
Parallel Async Operations
test('fetches users in parallel', async () => {
// Run all promises in parallel
const [user1, user2, user3] = await Promise.all([
fetchUser(1),
fetchUser(2),
fetchUser(3)
]);
expect(user1.id).toBe(1);
expect(user2.id).toBe(2);
expect(user3.id).toBe(3);
});
// Testing race conditions
test('first user to respond wins', async () => {
const fastestUser = await Promise.race([
fetchUser(1),
fetchUser(2),
fetchUser(3)
]);
expect(fastestUser).toBeDefined();
});
Testing Timers
Using Fake Timers
Real timers make tests slow. Jest provides fake timers that let you control time:
function delayedGreeting(name, callback) {
setTimeout(() => {
callback(`Hello, ${name}!`);
}, 1000);
}
test('calls greeting after 1 second', () => {
jest.useFakeTimers();
const callback = jest.fn();
delayedGreeting('John', callback);
// Fast-forward time by 1 second
jest.advanceTimersByTime(1000);
expect(callback).toHaveBeenCalledWith('Hello, John!');
jest.useRealTimers();
});
Running All Timers
function scheduledTasks() {
setTimeout(() => console.log('Task 1'), 100);
setTimeout(() => console.log('Task 2'), 200);
setTimeout(() => console.log('Task 3'), 300);
}
test('runs all scheduled tasks', () => {
jest.useFakeTimers();
const log = jest.spyOn(console, 'log');
scheduledTasks();
// Run all timers at once
jest.runAllTimers();
expect(log).toHaveBeenCalledTimes(3);
expect(log).toHaveBeenCalledWith('Task 1');
expect(log).toHaveBeenCalledWith('Task 2');
expect(log).toHaveBeenCalledWith('Task 3');
log.mockRestore();
jest.useRealTimers();
});
Testing setInterval
class Timer {
constructor() {
this.count = 0;
}
start() {
this.interval = setInterval(() => {
this.count++;
}, 1000);
}
stop() {
clearInterval(this.interval);
}
}
test('increments counter every second', () => {
jest.useFakeTimers();
const timer = new Timer();
timer.start();
expect(timer.count).toBe(0);
// Advance 3 seconds
jest.advanceTimersByTime(3000);
expect(timer.count).toBe(3);
timer.stop();
jest.useRealTimers();
});
Async Functions with Timers
async function fetchWithRetry(url, maxRetries = 3) {
for (let i = 0; i < maxRetries; i++) {
try {
return await fetch(url);
} catch (error) {
if (i === maxRetries - 1) throw error;
await new Promise(resolve => setTimeout(resolve, 1000 * (i + 1)));
}
}
}
test('retries failed requests with backoff', async () => {
jest.useFakeTimers();
const mockFetch = jest.fn()
.mockRejectedValueOnce(new Error('Fail 1'))
.mockRejectedValueOnce(new Error('Fail 2'))
.mockResolvedValueOnce({ data: 'success' });
global.fetch = mockFetch;
const promise = fetchWithRetry('/api/data');
// Fast-forward through retry delays
await jest.advanceTimersByTimeAsync(1000); // First retry after 1s
await jest.advanceTimersByTimeAsync(2000); // Second retry after 2s
const result = await promise;
expect(mockFetch).toHaveBeenCalledTimes(3);
expect(result).toEqual({ data: 'success' });
jest.useRealTimers();
});
Modern Timer Methods:
- jest.advanceTimersByTime(ms): Synchronously advances timers
- jest.advanceTimersByTimeAsync(ms): Async version for timers with promises
- jest.runAllTimers(): Runs all pending timers
- jest.runOnlyPendingTimers(): Runs only currently pending timers
- jest.clearAllTimers(): Removes all pending timers
Testing Event-Driven Code
Testing Event Emitters
const EventEmitter = require('events');
class UserService extends EventEmitter {
createUser(data) {
const user = { id: 1, ...data };
this.emit('user:created', user);
return user;
}
}
test('emits user:created event', (done) => {
const service = new UserService();
service.on('user:created', (user) => {
expect(user).toMatchObject({ name: 'John' });
done();
});
service.createUser({ name: 'John' });
});
// Using promises instead of done
test('emits user:created event', () => {
const service = new UserService();
return new Promise((resolve) => {
service.on('user:created', (user) => {
expect(user).toMatchObject({ name: 'John' });
resolve();
});
service.createUser({ name: 'John' });
});
});
Testing Multiple Events
class OrderProcessor extends EventEmitter {
async processOrder(order) {
this.emit('order:received', order);
await this.validateOrder(order);
this.emit('order:validated', order);
await this.chargePayment(order);
this.emit('order:paid', order);
return order;
}
async validateOrder(order) {
// Validation logic
}
async chargePayment(order) {
// Payment logic
}
}
test('emits events in correct order', async () => {
const processor = new OrderProcessor();
const events = [];
processor.on('order:received', () => events.push('received'));
processor.on('order:validated', () => events.push('validated'));
processor.on('order:paid', () => events.push('paid'));
await processor.processOrder({ id: 1, total: 100 });
expect(events).toEqual(['received', 'validated', 'paid']);
});
Testing DOM Events
// Button click handler
function handleButtonClick(button) {
button.addEventListener('click', () => {
button.textContent = 'Clicked!';
});
}
test('updates text on button click', () => {
// Create button element
const button = document.createElement('button');
button.textContent = 'Click me';
// Attach handler
handleButtonClick(button);
// Simulate click
button.click();
expect(button.textContent).toBe('Clicked!');
});
// Testing async event handlers
test('loads data on button click', async () => {
const button = document.createElement('button');
button.addEventListener('click', async () => {
const data = await fetchData();
button.textContent = data;
});
// Mock fetchData
global.fetchData = jest.fn(() => Promise.resolve('Loaded'));
// Click and wait
button.click();
await new Promise(resolve => setTimeout(resolve, 0));
expect(button.textContent).toBe('Loaded');
});
Best Practices
1. Prefer Async/Await
// Avoid - callback hell in tests
test('multiple operations', (done) => {
fetchUser(1, (user) => {
fetchOrders(user.id, (orders) => {
expect(orders.length).toBe(3);
done();
});
});
});
// Better - async/await
test('multiple operations', async () => {
const user = await fetchUser(1);
const orders = await fetchOrders(user.id);
expect(orders.length).toBe(3);
});
2. Set Appropriate Timeouts
// Increase timeout for slow operations
test('slow API call', async () => {
const data = await slowApiCall();
expect(data).toBeDefined();
}, 10000); // 10 second timeout
// Or set timeout for entire suite
describe('API tests', () => {
jest.setTimeout(10000);
test('fetches data', async () => {
// ...
});
});
3. Clean Up Timers and Listeners
let interval;
beforeEach(() => {
jest.useFakeTimers();
});
afterEach(() => {
if (interval) {
clearInterval(interval);
}
jest.clearAllTimers();
jest.useRealTimers();
});
test('starts interval', () => {
interval = setInterval(() => {}, 1000);
// Test logic
});
4. Use expect.assertions()
// Ensure assertions in promises actually run
test('catches error', async () => {
expect.assertions(1); // Must run 1 assertion
try {
await failingOperation();
} catch (error) {
expect(error.message).toBe('Failed');
}
});
Practice Exercise:
- Create a
CacheService that stores data with TTL (time-to-live)
- Write tests using fake timers to verify items expire correctly
- Create a
BatchProcessor that processes items in batches with delays
- Write async tests to verify batch processing order and timing
- Create an
EventBus class that emits events for subscribe/unsubscribe
- Write tests that verify multiple event listeners receive events correctly
Summary
Testing asynchronous code requires understanding promises, async/await, timers, and events. Modern testing frameworks like Jest provide excellent tools for testing async operations: return promises, use async/await syntax, control time with fake timers, and handle events properly. By following best practices - preferring async/await, cleaning up timers, and ensuring assertions run - you can write reliable, fast async tests that catch bugs before they reach production.