Testing & TDD

Testing Async Code: Promises, Timers, and Events

20 min Lesson 8 of 35

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:
  1. Create a CacheService that stores data with TTL (time-to-live)
  2. Write tests using fake timers to verify items expire correctly
  3. Create a BatchProcessor that processes items in batches with delays
  4. Write async tests to verify batch processing order and timing
  5. Create an EventBus class that emits events for subscribe/unsubscribe
  6. 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.