Prerequisites
- Understanding of TypeScript fundamentals and async programming π
- Knowledge of promises, callbacks, and async/await patterns β‘
- Familiarity with Jest testing framework and unit testing π»
What you'll learn
- Test asynchronous code with promises, callbacks, and async/await π―
- Handle timing issues and race conditions in async tests ποΈ
- Mock async dependencies and external services effectively π
- Build reliable test suites for complex async workflows β¨
π― Introduction
Welcome to the async testing laboratory of TypeScript! β‘ If testing synchronous code were like examining a photograph where everything is captured in a single moment, then testing asynchronous code would be like directing a complex movie scene where actions happen over time, actors enter and exit at different moments, and you need to coordinate everything perfectly to capture the story as it unfolds - except in our case, weβre ensuring our async operations work correctly across all timing scenarios!
Testing asynchronous code presents unique challenges: timing issues, race conditions, callback hell, and the need to wait for operations to complete. In TypeScript, we have the additional benefit of type safety for our async operations, but we still need to test that our promises resolve correctly, our callbacks are called with the right parameters, and our error handling works as expected.
By the end of this tutorial, youβll be a master of async testing in TypeScript, capable of testing everything from simple promise chains to complex async workflows involving multiple services, timeouts, and error conditions. Youβll learn to handle timing issues, mock async dependencies, and create reliable test suites that give you confidence in your async code. Letβs dive into the world of bulletproof async testing! π
π Understanding Async Testing Fundamentals
π€ Why Async Testing Is Different
Async testing requires special handling because operations donβt complete immediately, and we need to wait for results while handling potential errors and timeouts.
// π Setting up comprehensive async testing environment
// Types for our async testing examples
interface ApiResponse<T> {
data: T;
status: number;
message: string;
timestamp: Date;
}
interface User {
id: string;
name: string;
email: string;
isActive: boolean;
createdAt: Date;
}
interface ValidationError {
field: string;
message: string;
code: string;
}
interface DatabaseConnection {
connect(): Promise<void>;
disconnect(): Promise<void>;
query<T>(sql: string, params?: any[]): Promise<T[]>;
transaction<T>(callback: (tx: Transaction) => Promise<T>): Promise<T>;
}
interface Transaction {
query<T>(sql: string, params?: any[]): Promise<T[]>;
commit(): Promise<void>;
rollback(): Promise<void>;
}
interface EmailService {
sendEmail(to: string, subject: string, body: string): Promise<void>;
validateEmail(email: string): Promise<boolean>;
}
interface CacheService {
get<T>(key: string): Promise<T | null>;
set<T>(key: string, value: T, ttl?: number): Promise<void>;
delete(key: string): Promise<boolean>;
clear(): Promise<void>;
}
// Service that performs async operations
class UserService {
constructor(
private db: DatabaseConnection,
private emailService: EmailService,
private cache: CacheService
) {}
// Promise-based async method
async createUser(userData: Omit<User, 'id' | 'createdAt'>): Promise<User> {
// Validate email first
const isValidEmail = await this.emailService.validateEmail(userData.email);
if (!isValidEmail) {
throw new Error('Invalid email address');
}
// Check if user exists
const existingUsers = await this.db.query<User>(
'SELECT * FROM users WHERE email = ?',
[userData.email]
);
if (existingUsers.length > 0) {
throw new Error('User already exists');
}
// Create user
const user: User = {
id: this.generateId(),
...userData,
createdAt: new Date()
};
await this.db.query(
'INSERT INTO users (id, name, email, isActive, createdAt) VALUES (?, ?, ?, ?, ?)',
[user.id, user.name, user.email, user.isActive, user.createdAt]
);
// Cache the user
await this.cache.set(`user:${user.id}`, user, 3600);
// Send welcome email (don't wait for it)
this.emailService.sendEmail(
user.email,
'Welcome!',
`Welcome ${user.name}!`
).catch(error => {
console.error('Failed to send welcome email:', error);
});
return user;
}
// Callback-based async method (legacy style)
getUserById(id: string, callback: (error: Error | null, user?: User) => void): void {
// Simulate async operation with setTimeout
setTimeout(async () => {
try {
// Check cache first
const cachedUser = await this.cache.get<User>(`user:${id}`);
if (cachedUser) {
callback(null, cachedUser);
return;
}
// Query database
const users = await this.db.query<User>(
'SELECT * FROM users WHERE id = ?',
[id]
);
if (users.length === 0) {
callback(new Error('User not found'));
return;
}
const user = users[0];
// Cache for next time
await this.cache.set(`user:${user.id}`, user, 3600);
callback(null, user);
} catch (error) {
callback(error as Error);
}
}, 100);
}
// Promise-based method with timeout
async getUserWithTimeout(id: string, timeoutMs: number = 5000): Promise<User> {
return new Promise((resolve, reject) => {
const timeout = setTimeout(() => {
reject(new Error('Operation timed out'));
}, timeoutMs);
this.getUserById(id, (error, user) => {
clearTimeout(timeout);
if (error) {
reject(error);
} else if (user) {
resolve(user);
} else {
reject(new Error('Unknown error'));
}
});
});
}
// Complex async workflow with multiple steps
async processUserRegistration(userData: Omit<User, 'id' | 'createdAt'>): Promise<{
user: User;
emailSent: boolean;
cacheUpdated: boolean;
}> {
let user: User;
let emailSent = false;
let cacheUpdated = false;
// Use transaction for database operations
await this.db.transaction(async (tx) => {
// Create user
user = {
id: this.generateId(),
...userData,
createdAt: new Date()
};
await tx.query(
'INSERT INTO users (id, name, email, isActive, createdAt) VALUES (?, ?, ?, ?, ?)',
[user.id, user.name, user.email, user.isActive, user.createdAt]
);
// Log registration
await tx.query(
'INSERT INTO user_activity (user_id, action, timestamp) VALUES (?, ?, ?)',
[user.id, 'registration', new Date()]
);
});
// Parallel operations after transaction
const [cacheResult, emailResult] = await Promise.allSettled([
this.cache.set(`user:${user!.id}`, user!, 3600),
this.emailService.sendEmail(
user!.email,
'Welcome!',
`Welcome ${user!.name}!`
)
]);
cacheUpdated = cacheResult.status === 'fulfilled';
emailSent = emailResult.status === 'fulfilled';
return {
user: user!,
emailSent,
cacheUpdated
};
}
private generateId(): string {
return Math.random().toString(36).substr(2, 9);
}
}
// Utility class for handling async operations
class AsyncOperationManager {
private operations: Map<string, Promise<any>> = new Map();
// Register an async operation
registerOperation<T>(key: string, operation: Promise<T>): Promise<T> {
this.operations.set(key, operation);
// Clean up when done
operation.finally(() => {
this.operations.delete(key);
});
return operation;
}
// Wait for all operations to complete
async waitForAll(): Promise<void> {
await Promise.allSettled(Array.from(this.operations.values()));
}
// Cancel all pending operations (if they support cancellation)
cancelAll(): void {
this.operations.clear();
}
// Get status of operations
getStatus(): {
pending: number;
keys: string[];
} {
return {
pending: this.operations.size,
keys: Array.from(this.operations.keys())
};
}
}
// API client for external service calls
class ApiClient {
constructor(private baseUrl: string) {}
async get<T>(endpoint: string): Promise<ApiResponse<T>> {
const response = await fetch(`${this.baseUrl}${endpoint}`);
const data = await response.json();
return {
data,
status: response.status,
message: response.statusText,
timestamp: new Date()
};
}
async post<T, R>(endpoint: string, data: T): Promise<ApiResponse<R>> {
const response = await fetch(`${this.baseUrl}${endpoint}`, {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify(data)
});
const responseData = await response.json();
return {
data: responseData,
status: response.status,
message: response.statusText,
timestamp: new Date()
};
}
// Method with retry logic
async getWithRetry<T>(
endpoint: string,
maxRetries: number = 3,
delay: number = 1000
): Promise<ApiResponse<T>> {
let lastError: Error;
for (let attempt = 0; attempt <= maxRetries; attempt++) {
try {
return await this.get<T>(endpoint);
} catch (error) {
lastError = error as Error;
if (attempt === maxRetries) {
throw lastError;
}
// Wait before retry
await new Promise(resolve => setTimeout(resolve, delay * (attempt + 1)));
}
}
throw lastError!;
}
}
π§ͺ Testing Promise-Based Async Code
β Basic Promise Testing
The most straightforward way to test promises is using async/await in your test functions.
// π Comprehensive promise testing patterns
describe('UserService - Promise Testing', () => {
let userService: UserService;
let mockDb: jest.Mocked<DatabaseConnection>;
let mockEmailService: jest.Mocked<EmailService>;
let mockCache: jest.Mocked<CacheService>;
beforeEach(() => {
// Create mocks
mockDb = {
connect: jest.fn(),
disconnect: jest.fn(),
query: jest.fn(),
transaction: jest.fn()
};
mockEmailService = {
sendEmail: jest.fn(),
validateEmail: jest.fn()
};
mockCache = {
get: jest.fn(),
set: jest.fn(),
delete: jest.fn(),
clear: jest.fn()
};
userService = new UserService(mockDb, mockEmailService, mockCache);
});
// β
Test successful promise resolution
describe('createUser', () => {
it('should create user successfully when all validations pass', async () => {
// Arrange
const userData = {
name: 'John Doe',
email: '[email protected]',
isActive: true
};
mockEmailService.validateEmail.mockResolvedValue(true);
mockDb.query
.mockResolvedValueOnce([]) // No existing users
.mockResolvedValueOnce([]); // Insert successful
mockCache.set.mockResolvedValue();
mockEmailService.sendEmail.mockResolvedValue();
// Act
const result = await userService.createUser(userData);
// Assert
expect(result).toMatchObject({
name: userData.name,
email: userData.email,
isActive: userData.isActive
});
expect(result.id).toBeDefined();
expect(result.createdAt).toBeInstanceOf(Date);
// Verify all async operations were called
expect(mockEmailService.validateEmail).toHaveBeenCalledWith(userData.email);
expect(mockDb.query).toHaveBeenCalledTimes(2);
expect(mockCache.set).toHaveBeenCalledWith(
`user:${result.id}`,
result,
3600
);
});
// β
Test promise rejection with validation error
it('should reject when email validation fails', async () => {
// Arrange
const userData = {
name: 'John Doe',
email: 'invalid-email',
isActive: true
};
mockEmailService.validateEmail.mockResolvedValue(false);
// Act & Assert
await expect(userService.createUser(userData))
.rejects
.toThrow('Invalid email address');
// Verify no database operations occurred
expect(mockDb.query).not.toHaveBeenCalled();
expect(mockCache.set).not.toHaveBeenCalled();
});
// β
Test promise rejection with database error
it('should reject when database operation fails', async () => {
// Arrange
const userData = {
name: 'John Doe',
email: '[email protected]',
isActive: true
};
mockEmailService.validateEmail.mockResolvedValue(true);
mockDb.query.mockRejectedValue(new Error('Database connection failed'));
// Act & Assert
await expect(userService.createUser(userData))
.rejects
.toThrow('Database connection failed');
// Verify email service was called but cache was not
expect(mockEmailService.validateEmail).toHaveBeenCalled();
expect(mockCache.set).not.toHaveBeenCalled();
});
// β
Test promise rejection with existing user
it('should reject when user already exists', async () => {
// Arrange
const userData = {
name: 'John Doe',
email: '[email protected]',
isActive: true
};
const existingUser = {
id: 'existing-id',
...userData,
createdAt: new Date()
};
mockEmailService.validateEmail.mockResolvedValue(true);
mockDb.query.mockResolvedValue([existingUser]);
// Act & Assert
await expect(userService.createUser(userData))
.rejects
.toThrow('User already exists');
// Verify database was queried but no insert occurred
expect(mockDb.query).toHaveBeenCalledTimes(1);
expect(mockCache.set).not.toHaveBeenCalled();
});
});
// β
Testing complex async workflows
describe('processUserRegistration', () => {
it('should handle complex registration workflow', async () => {
// Arrange
const userData = {
name: 'Jane Doe',
email: '[email protected]',
isActive: true
};
const mockTransaction = {
query: jest.fn().mockResolvedValue([]),
commit: jest.fn(),
rollback: jest.fn()
};
mockDb.transaction.mockImplementation(async (callback) => {
return await callback(mockTransaction);
});
mockCache.set.mockResolvedValue();
mockEmailService.sendEmail.mockResolvedValue();
// Act
const result = await userService.processUserRegistration(userData);
// Assert
expect(result.user).toMatchObject({
name: userData.name,
email: userData.email,
isActive: userData.isActive
});
expect(result.emailSent).toBe(true);
expect(result.cacheUpdated).toBe(true);
// Verify transaction operations
expect(mockTransaction.query).toHaveBeenCalledTimes(2);
expect(mockCache.set).toHaveBeenCalled();
expect(mockEmailService.sendEmail).toHaveBeenCalled();
});
it('should handle partial failures gracefully', async () => {
// Arrange
const userData = {
name: 'Jane Doe',
email: '[email protected]',
isActive: true
};
const mockTransaction = {
query: jest.fn().mockResolvedValue([]),
commit: jest.fn(),
rollback: jest.fn()
};
mockDb.transaction.mockImplementation(async (callback) => {
return await callback(mockTransaction);
});
// Cache succeeds, email fails
mockCache.set.mockResolvedValue();
mockEmailService.sendEmail.mockRejectedValue(new Error('Email service down'));
// Act
const result = await userService.processUserRegistration(userData);
// Assert
expect(result.user).toBeDefined();
expect(result.emailSent).toBe(false); // Email failed
expect(result.cacheUpdated).toBe(true); // Cache succeeded
});
});
});
// Advanced promise testing patterns
describe('Advanced Promise Testing', () => {
// β
Testing promise timing and order
it('should handle promise execution order correctly', async () => {
const executionOrder: string[] = [];
const promise1 = new Promise<void>(resolve => {
setTimeout(() => {
executionOrder.push('promise1');
resolve();
}, 100);
});
const promise2 = new Promise<void>(resolve => {
setTimeout(() => {
executionOrder.push('promise2');
resolve();
}, 50);
});
const promise3 = new Promise<void>(resolve => {
setTimeout(() => {
executionOrder.push('promise3');
resolve();
}, 150);
});
// Wait for all promises
await Promise.all([promise1, promise2, promise3]);
// Verify execution order (shortest timeout first)
expect(executionOrder).toEqual(['promise2', 'promise1', 'promise3']);
});
// β
Testing Promise.allSettled behavior
it('should handle mixed success and failure with allSettled', async () => {
const promises = [
Promise.resolve('success1'),
Promise.reject(new Error('error1')),
Promise.resolve('success2'),
Promise.reject(new Error('error2'))
];
const results = await Promise.allSettled(promises);
expect(results).toHaveLength(4);
expect(results[0].status).toBe('fulfilled');
expect(results[1].status).toBe('rejected');
expect(results[2].status).toBe('fulfilled');
expect(results[3].status).toBe('rejected');
// Type-safe access to results
if (results[0].status === 'fulfilled') {
expect(results[0].value).toBe('success1');
}
if (results[1].status === 'rejected') {
expect(results[1].reason).toBeInstanceOf(Error);
expect(results[1].reason.message).toBe('error1');
}
});
// β
Testing promise race conditions
it('should handle promise race correctly', async () => {
const fastPromise = new Promise<string>(resolve => {
setTimeout(() => resolve('fast'), 50);
});
const slowPromise = new Promise<string>(resolve => {
setTimeout(() => resolve('slow'), 200);
});
const result = await Promise.race([slowPromise, fastPromise]);
expect(result).toBe('fast');
});
});
π Testing Callback-Based Async Code
π Handling Legacy Callback Patterns
Many legacy APIs use callbacks instead of promises. Hereβs how to test them effectively.
// π Comprehensive callback testing patterns
describe('UserService - Callback Testing', () => {
let userService: UserService;
let mockDb: jest.Mocked<DatabaseConnection>;
let mockEmailService: jest.Mocked<EmailService>;
let mockCache: jest.Mocked<CacheService>;
beforeEach(() => {
mockDb = {
connect: jest.fn(),
disconnect: jest.fn(),
query: jest.fn(),
transaction: jest.fn()
};
mockEmailService = {
sendEmail: jest.fn(),
validateEmail: jest.fn()
};
mockCache = {
get: jest.fn(),
set: jest.fn(),
delete: jest.fn(),
clear: jest.fn()
};
userService = new UserService(mockDb, mockEmailService, mockCache);
});
// β
Test successful callback execution
describe('getUserById (callback)', () => {
it('should call callback with user when found in cache', (done) => {
// Arrange
const userId = 'test-user-id';
const cachedUser: User = {
id: userId,
name: 'John Doe',
email: '[email protected]',
isActive: true,
createdAt: new Date()
};
mockCache.get.mockResolvedValue(cachedUser);
// Act
userService.getUserById(userId, (error, user) => {
try {
// Assert
expect(error).toBeNull();
expect(user).toEqual(cachedUser);
expect(mockCache.get).toHaveBeenCalledWith(`user:${userId}`);
// Don't expect database query since cache hit
expect(mockDb.query).not.toHaveBeenCalled();
done();
} catch (err) {
done(err);
}
});
});
it('should call callback with user when found in database', (done) => {
// Arrange
const userId = 'test-user-id';
const dbUser: User = {
id: userId,
name: 'Jane Doe',
email: '[email protected]',
isActive: true,
createdAt: new Date()
};
mockCache.get.mockResolvedValue(null); // Cache miss
mockDb.query.mockResolvedValue([dbUser]);
mockCache.set.mockResolvedValue();
// Act
userService.getUserById(userId, (error, user) => {
try {
// Assert
expect(error).toBeNull();
expect(user).toEqual(dbUser);
expect(mockCache.get).toHaveBeenCalledWith(`user:${userId}`);
expect(mockDb.query).toHaveBeenCalledWith(
'SELECT * FROM users WHERE id = ?',
[userId]
);
expect(mockCache.set).toHaveBeenCalledWith(`user:${userId}`, dbUser, 3600);
done();
} catch (err) {
done(err);
}
});
});
it('should call callback with error when user not found', (done) => {
// Arrange
const userId = 'nonexistent-user';
mockCache.get.mockResolvedValue(null);
mockDb.query.mockResolvedValue([]); // No users found
// Act
userService.getUserById(userId, (error, user) => {
try {
// Assert
expect(error).toBeInstanceOf(Error);
expect(error?.message).toBe('User not found');
expect(user).toBeUndefined();
done();
} catch (err) {
done(err);
}
});
});
it('should call callback with error when database fails', (done) => {
// Arrange
const userId = 'test-user-id';
const dbError = new Error('Database connection failed');
mockCache.get.mockResolvedValue(null);
mockDb.query.mockRejectedValue(dbError);
// Act
userService.getUserById(userId, (error, user) => {
try {
// Assert
expect(error).toBe(dbError);
expect(user).toBeUndefined();
done();
} catch (err) {
done(err);
}
});
});
});
// β
Converting callback tests to promise-based tests
describe('getUserById (promisified)', () => {
// Helper function to promisify callback-based method
const getUserByIdPromise = (id: string): Promise<User> => {
return new Promise((resolve, reject) => {
userService.getUserById(id, (error, user) => {
if (error) {
reject(error);
} else if (user) {
resolve(user);
} else {
reject(new Error('Unknown error'));
}
});
});
};
it('should resolve with user when found', async () => {
// Arrange
const userId = 'test-user-id';
const user: User = {
id: userId,
name: 'John Doe',
email: '[email protected]',
isActive: true,
createdAt: new Date()
};
mockCache.get.mockResolvedValue(user);
// Act & Assert
await expect(getUserByIdPromise(userId)).resolves.toEqual(user);
});
it('should reject when user not found', async () => {
// Arrange
const userId = 'nonexistent-user';
mockCache.get.mockResolvedValue(null);
mockDb.query.mockResolvedValue([]);
// Act & Assert
await expect(getUserByIdPromise(userId))
.rejects
.toThrow('User not found');
});
});
// β
Testing callback timeouts
describe('getUserWithTimeout', () => {
it('should resolve within timeout', async () => {
// Arrange
const userId = 'test-user-id';
const user: User = {
id: userId,
name: 'John Doe',
email: '[email protected]',
isActive: true,
createdAt: new Date()
};
mockCache.get.mockResolvedValue(user);
// Act & Assert
await expect(userService.getUserWithTimeout(userId, 1000))
.resolves
.toEqual(user);
});
it('should reject when operation times out', async () => {
// Arrange
const userId = 'test-user-id';
// Make cache operation take longer than timeout
mockCache.get.mockImplementation(() =>
new Promise(resolve => setTimeout(() => resolve(null), 200))
);
mockDb.query.mockImplementation(() =>
new Promise(resolve => setTimeout(() => resolve([]), 200))
);
// Act & Assert
await expect(userService.getUserWithTimeout(userId, 100))
.rejects
.toThrow('Operation timed out');
});
});
});
// Advanced callback testing patterns
describe('Advanced Callback Testing', () => {
// β
Testing multiple callback invocations
it('should handle multiple callback calls correctly', (done) => {
let callCount = 0;
const expectedCalls = 3;
const callback = (result: string) => {
callCount++;
expect(result).toBe(`result-${callCount}`);
if (callCount === expectedCalls) {
done();
}
};
// Simulate multiple async operations
for (let i = 1; i <= expectedCalls; i++) {
setTimeout(() => {
callback(`result-${i}`);
}, i * 10);
}
});
// β
Testing callback error handling
it('should handle callback errors gracefully', (done) => {
const errors: Error[] = [];
const errorCallback = (error: Error | null, result?: string) => {
if (error) {
errors.push(error);
}
if (errors.length === 2) {
expect(errors).toHaveLength(2);
expect(errors[0].message).toBe('First error');
expect(errors[1].message).toBe('Second error');
done();
}
};
// Simulate multiple error scenarios
setTimeout(() => {
errorCallback(new Error('First error'));
}, 10);
setTimeout(() => {
errorCallback(new Error('Second error'));
}, 20);
});
// β
Testing callback with custom matchers
it('should use custom matchers for callback validation', (done) => {
const customCallback = (data: { timestamp: Date; value: number }) => {
try {
expect(data).toEqual({
timestamp: expect.any(Date),
value: expect.any(Number)
});
expect(data.timestamp.getTime()).toBeCloseTo(Date.now(), -2);
expect(data.value).toBeGreaterThan(0);
done();
} catch (error) {
done(error);
}
};
setTimeout(() => {
customCallback({
timestamp: new Date(),
value: Math.random() * 100
});
}, 50);
});
});
β° Testing Timing and Race Conditions
πββοΈ Handling Time-Sensitive Operations
Time-sensitive operations require special testing techniques to ensure reliability and avoid flaky tests.
// π Comprehensive timing and race condition testing
describe('Timing and Race Condition Testing', () => {
// β
Testing with fake timers
describe('Timer-based operations', () => {
beforeEach(() => {
jest.useFakeTimers();
});
afterEach(() => {
jest.useRealTimers();
});
it('should execute delayed operation after specified time', () => {
// Arrange
const callback = jest.fn();
const delay = 1000;
// Act
setTimeout(callback, delay);
// Assert - callback should not be called yet
expect(callback).not.toHaveBeenCalled();
// Fast-forward time
jest.advanceTimersByTime(delay);
// Assert - callback should now be called
expect(callback).toHaveBeenCalledTimes(1);
});
it('should handle multiple timers correctly', () => {
// Arrange
const callbacks = {
fast: jest.fn(),
medium: jest.fn(),
slow: jest.fn()
};
// Act - set up multiple timers
setTimeout(callbacks.fast, 100);
setTimeout(callbacks.medium, 500);
setTimeout(callbacks.slow, 1000);
// Assert - no callbacks called yet
expect(callbacks.fast).not.toHaveBeenCalled();
expect(callbacks.medium).not.toHaveBeenCalled();
expect(callbacks.slow).not.toHaveBeenCalled();
// Fast-forward to 100ms
jest.advanceTimersByTime(100);
expect(callbacks.fast).toHaveBeenCalledTimes(1);
expect(callbacks.medium).not.toHaveBeenCalled();
expect(callbacks.slow).not.toHaveBeenCalled();
// Fast-forward to 500ms total
jest.advanceTimersByTime(400);
expect(callbacks.fast).toHaveBeenCalledTimes(1);
expect(callbacks.medium).toHaveBeenCalledTimes(1);
expect(callbacks.slow).not.toHaveBeenCalled();
// Fast-forward to 1000ms total
jest.advanceTimersByTime(500);
expect(callbacks.fast).toHaveBeenCalledTimes(1);
expect(callbacks.medium).toHaveBeenCalledTimes(1);
expect(callbacks.slow).toHaveBeenCalledTimes(1);
});
it('should handle interval operations', () => {
// Arrange
const callback = jest.fn();
const interval = 250;
// Act
const intervalId = setInterval(callback, interval);
// Assert - advance time and check calls
jest.advanceTimersByTime(interval);
expect(callback).toHaveBeenCalledTimes(1);
jest.advanceTimersByTime(interval);
expect(callback).toHaveBeenCalledTimes(2);
jest.advanceTimersByTime(interval);
expect(callback).toHaveBeenCalledTimes(3);
// Clean up
clearInterval(intervalId);
});
});
// β
Testing debounce and throttle functions
describe('Debounce and Throttle', () => {
// Simple debounce implementation for testing
function debounce<T extends (...args: any[]) => any>(
func: T,
wait: number
): T {
let timeout: NodeJS.Timeout;
return ((...args: Parameters<T>) => {
clearTimeout(timeout);
timeout = setTimeout(() => func(...args), wait);
}) as T;
}
// Simple throttle implementation for testing
function throttle<T extends (...args: any[]) => any>(
func: T,
wait: number
): T {
let lastTime = 0;
return ((...args: Parameters<T>) => {
const now = Date.now();
if (now - lastTime >= wait) {
lastTime = now;
return func(...args);
}
}) as T;
}
beforeEach(() => {
jest.useFakeTimers();
});
afterEach(() => {
jest.useRealTimers();
});
it('should debounce function calls correctly', () => {
// Arrange
const originalFunc = jest.fn();
const debouncedFunc = debounce(originalFunc, 100);
// Act - call multiple times rapidly
debouncedFunc('call1');
debouncedFunc('call2');
debouncedFunc('call3');
// Assert - no calls yet
expect(originalFunc).not.toHaveBeenCalled();
// Fast-forward time
jest.advanceTimersByTime(100);
// Assert - only last call should be executed
expect(originalFunc).toHaveBeenCalledTimes(1);
expect(originalFunc).toHaveBeenCalledWith('call3');
});
it('should throttle function calls correctly', () => {
// Arrange
const originalFunc = jest.fn();
const throttledFunc = throttle(originalFunc, 100);
// Mock Date.now
let currentTime = 0;
jest.spyOn(Date, 'now').mockImplementation(() => currentTime);
// Act & Assert
throttledFunc('call1');
expect(originalFunc).toHaveBeenCalledTimes(1);
expect(originalFunc).toHaveBeenCalledWith('call1');
// Call again immediately - should be ignored
throttledFunc('call2');
expect(originalFunc).toHaveBeenCalledTimes(1);
// Advance time and call again
currentTime += 100;
throttledFunc('call3');
expect(originalFunc).toHaveBeenCalledTimes(2);
expect(originalFunc).toHaveBeenCalledWith('call3');
});
});
// β
Testing race conditions
describe('Race Conditions', () => {
it('should handle concurrent async operations correctly', async () => {
// Arrange
const results: string[] = [];
const delays = [100, 50, 150, 75];
const createAsyncOperation = (id: string, delay: number) => {
return new Promise<void>(resolve => {
setTimeout(() => {
results.push(id);
resolve();
}, delay);
});
};
// Act - start all operations concurrently
const operations = delays.map((delay, index) =>
createAsyncOperation(`op${index + 1}`, delay)
);
await Promise.all(operations);
// Assert - results should be in order of completion (by delay)
expect(results).toEqual(['op2', 'op4', 'op1', 'op3']);
});
it('should handle resource contention correctly', async () => {
// Arrange
let sharedResource = 0;
const operations: Promise<number>[] = [];
const incrementResource = async (increment: number, delay: number): Promise<number> => {
// Simulate async operation
await new Promise(resolve => setTimeout(resolve, delay));
// Critical section - potential race condition
const currentValue = sharedResource;
await new Promise(resolve => setTimeout(resolve, 10)); // Simulate processing time
sharedResource = currentValue + increment;
return sharedResource;
};
// Act - start multiple operations that modify shared resource
for (let i = 1; i <= 5; i++) {
operations.push(incrementResource(i, Math.random() * 50));
}
const results = await Promise.all(operations);
// Assert - verify final state
expect(sharedResource).toBeGreaterThan(0);
expect(results).toHaveLength(5);
// Each result should reflect the state at the time of completion
results.forEach(result => {
expect(result).toBeGreaterThan(0);
});
});
it('should handle async resource cleanup correctly', async () => {
// Arrange
const resources: string[] = [];
const cleanupOrder: string[] = [];
const createResource = async (id: string): Promise<() => Promise<void>> => {
resources.push(id);
// Return cleanup function
return async () => {
await new Promise(resolve => setTimeout(resolve, Math.random() * 50));
cleanupOrder.push(id);
const index = resources.indexOf(id);
if (index > -1) {
resources.splice(index, 1);
}
};
};
// Act - create resources and cleanup functions
const cleanup1 = await createResource('resource1');
const cleanup2 = await createResource('resource2');
const cleanup3 = await createResource('resource3');
expect(resources).toEqual(['resource1', 'resource2', 'resource3']);
// Clean up concurrently
await Promise.all([cleanup1(), cleanup2(), cleanup3()]);
// Assert - all resources cleaned up
expect(resources).toEqual([]);
expect(cleanupOrder).toHaveLength(3);
expect(cleanupOrder).toEqual(expect.arrayContaining(['resource1', 'resource2', 'resource3']));
});
});
});
// Real-world async testing scenarios
describe('Real-World Async Testing', () => {
let apiClient: ApiClient;
beforeEach(() => {
apiClient = new ApiClient('https://api.example.com');
// Mock fetch globally
global.fetch = jest.fn();
});
afterEach(() => {
jest.resetAllMocks();
});
// β
Testing API retry logic
describe('API Retry Logic', () => {
it('should retry failed requests and eventually succeed', async () => {
// Arrange
const mockFetch = global.fetch as jest.MockedFunction<typeof fetch>;
// First two calls fail, third succeeds
mockFetch
.mockRejectedValueOnce(new Error('Network error'))
.mockRejectedValueOnce(new Error('Service unavailable'))
.mockResolvedValueOnce({
ok: true,
status: 200,
statusText: 'OK',
json: () => Promise.resolve({ id: 1, name: 'Test User' })
} as Response);
// Act
const result = await apiClient.getWithRetry<{ id: number; name: string }>('/users/1');
// Assert
expect(result.status).toBe(200);
expect(result.data).toEqual({ id: 1, name: 'Test User' });
expect(mockFetch).toHaveBeenCalledTimes(3);
});
it('should fail after maximum retries', async () => {
// Arrange
const mockFetch = global.fetch as jest.MockedFunction<typeof fetch>;
// All calls fail
mockFetch.mockRejectedValue(new Error('Persistent error'));
// Act & Assert
await expect(apiClient.getWithRetry('/users/1', 2))
.rejects
.toThrow('Persistent error');
expect(mockFetch).toHaveBeenCalledTimes(3); // Initial + 2 retries
});
});
// β
Testing concurrent operations
describe('Concurrent Operations', () => {
it('should handle multiple concurrent API calls', async () => {
// Arrange
const mockFetch = global.fetch as jest.MockedFunction<typeof fetch>;
const users = [
{ id: 1, name: 'User 1' },
{ id: 2, name: 'User 2' },
{ id: 3, name: 'User 3' }
];
mockFetch.mockImplementation((url) => {
const userId = url.toString().split('/').pop();
const user = users.find(u => u.id.toString() === userId);
return Promise.resolve({
ok: true,
status: 200,
statusText: 'OK',
json: () => Promise.resolve(user)
} as Response);
});
// Act - make concurrent requests
const requests = users.map(user =>
apiClient.get<typeof user>(`/users/${user.id}`)
);
const results = await Promise.all(requests);
// Assert
expect(results).toHaveLength(3);
results.forEach((result, index) => {
expect(result.status).toBe(200);
expect(result.data).toEqual(users[index]);
});
expect(mockFetch).toHaveBeenCalledTimes(3);
});
it('should handle mixed success and failure in concurrent operations', async () => {
// Arrange
const mockFetch = global.fetch as jest.MockedFunction<typeof fetch>;
mockFetch
.mockResolvedValueOnce({
ok: true,
status: 200,
statusText: 'OK',
json: () => Promise.resolve({ id: 1, name: 'User 1' })
} as Response)
.mockRejectedValueOnce(new Error('Not found'))
.mockResolvedValueOnce({
ok: true,
status: 200,
statusText: 'OK',
json: () => Promise.resolve({ id: 3, name: 'User 3' })
} as Response);
// Act
const requests = [
apiClient.get('/users/1'),
apiClient.get('/users/2'),
apiClient.get('/users/3')
];
const results = await Promise.allSettled(requests);
// Assert
expect(results).toHaveLength(3);
expect(results[0].status).toBe('fulfilled');
expect(results[1].status).toBe('rejected');
expect(results[2].status).toBe('fulfilled');
if (results[0].status === 'fulfilled') {
expect(results[0].value.data).toEqual({ id: 1, name: 'User 1' });
}
if (results[1].status === 'rejected') {
expect(results[1].reason).toBeInstanceOf(Error);
expect(results[1].reason.message).toBe('Not found');
}
if (results[2].status === 'fulfilled') {
expect(results[2].value.data).toEqual({ id: 3, name: 'User 3' });
}
});
});
});
π Mocking Async Dependencies
π§ Advanced Async Mocking Patterns
Mocking async dependencies requires careful handling of timing, promises, and side effects.
// π Comprehensive async mocking patterns
describe('Advanced Async Mocking', () => {
// β
Mocking async functions with different behaviors
describe('Async Function Mocking', () => {
interface WeatherService {
getCurrentWeather(city: string): Promise<{
temperature: number;
humidity: number;
description: string;
}>;
getWeatherForecast(city: string, days: number): Promise<Array<{
date: string;
temperature: number;
description: string;
}>>;
}
let mockWeatherService: jest.Mocked<WeatherService>;
beforeEach(() => {
mockWeatherService = {
getCurrentWeather: jest.fn(),
getWeatherForecast: jest.fn()
};
});
it('should mock successful async operations', async () => {
// Arrange
const expectedWeather = {
temperature: 25,
humidity: 60,
description: 'Sunny'
};
mockWeatherService.getCurrentWeather.mockResolvedValue(expectedWeather);
// Act
const result = await mockWeatherService.getCurrentWeather('London');
// Assert
expect(result).toEqual(expectedWeather);
expect(mockWeatherService.getCurrentWeather).toHaveBeenCalledWith('London');
});
it('should mock async operations with delays', async () => {
// Arrange
const expectedWeather = {
temperature: 20,
humidity: 70,
description: 'Cloudy'
};
mockWeatherService.getCurrentWeather.mockImplementation((city) => {
return new Promise(resolve => {
setTimeout(() => {
resolve(expectedWeather);
}, 100);
});
});
// Act
const startTime = Date.now();
const result = await mockWeatherService.getCurrentWeather('Paris');
const endTime = Date.now();
// Assert
expect(result).toEqual(expectedWeather);
expect(endTime - startTime).toBeGreaterThanOrEqual(100);
});
it('should mock async operations with conditional behavior', async () => {
// Arrange
mockWeatherService.getCurrentWeather.mockImplementation(async (city) => {
if (city === 'InvalidCity') {
throw new Error('City not found');
}
if (city === 'London') {
return {
temperature: 18,
humidity: 65,
description: 'Rainy'
};
}
return {
temperature: 25,
humidity: 50,
description: 'Sunny'
};
});
// Act & Assert
await expect(mockWeatherService.getCurrentWeather('InvalidCity'))
.rejects
.toThrow('City not found');
const londonWeather = await mockWeatherService.getCurrentWeather('London');
expect(londonWeather.description).toBe('Rainy');
const defaultWeather = await mockWeatherService.getCurrentWeather('Tokyo');
expect(defaultWeather.description).toBe('Sunny');
});
it('should mock async operations with sequence of responses', async () => {
// Arrange - different responses for multiple calls
mockWeatherService.getCurrentWeather
.mockResolvedValueOnce({
temperature: 20,
humidity: 60,
description: 'Morning clouds'
})
.mockResolvedValueOnce({
temperature: 25,
humidity: 55,
description: 'Afternoon sun'
})
.mockResolvedValueOnce({
temperature: 18,
humidity: 70,
description: 'Evening rain'
});
// Act
const morning = await mockWeatherService.getCurrentWeather('London');
const afternoon = await mockWeatherService.getCurrentWeather('London');
const evening = await mockWeatherService.getCurrentWeather('London');
// Assert
expect(morning.description).toBe('Morning clouds');
expect(afternoon.description).toBe('Afternoon sun');
expect(evening.description).toBe('Evening rain');
});
});
// β
Mocking external API calls
describe('External API Mocking', () => {
class ExternalApiService {
async fetchUserData(userId: string): Promise<{
id: string;
name: string;
email: string;
}> {
const response = await fetch(`https://api.example.com/users/${userId}`);
if (!response.ok) {
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
}
return response.json();
}
async createUser(userData: {
name: string;
email: string;
}): Promise<{
id: string;
name: string;
email: string;
createdAt: string;
}> {
const response = await fetch('https://api.example.com/users', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify(userData)
});
if (!response.ok) {
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
}
return response.json();
}
}
let apiService: ExternalApiService;
let mockFetch: jest.MockedFunction<typeof fetch>;
beforeEach(() => {
apiService = new ExternalApiService();
mockFetch = jest.fn();
global.fetch = mockFetch;
});
afterEach(() => {
jest.resetAllMocks();
});
it('should mock successful API response', async () => {
// Arrange
const expectedUser = {
id: 'user-123',
name: 'John Doe',
email: '[email protected]'
};
mockFetch.mockResolvedValue({
ok: true,
status: 200,
statusText: 'OK',
json: () => Promise.resolve(expectedUser)
} as Response);
// Act
const result = await apiService.fetchUserData('user-123');
// Assert
expect(result).toEqual(expectedUser);
expect(mockFetch).toHaveBeenCalledWith('https://api.example.com/users/user-123');
});
it('should mock API error responses', async () => {
// Arrange
mockFetch.mockResolvedValue({
ok: false,
status: 404,
statusText: 'Not Found'
} as Response);
// Act & Assert
await expect(apiService.fetchUserData('nonexistent'))
.rejects
.toThrow('HTTP 404: Not Found');
expect(mockFetch).toHaveBeenCalledWith('https://api.example.com/users/nonexistent');
});
it('should mock POST requests with request body validation', async () => {
// Arrange
const userData = {
name: 'Jane Doe',
email: '[email protected]'
};
const expectedResponse = {
id: 'user-456',
...userData,
createdAt: '2023-01-01T00:00:00Z'
};
mockFetch.mockResolvedValue({
ok: true,
status: 201,
statusText: 'Created',
json: () => Promise.resolve(expectedResponse)
} as Response);
// Act
const result = await apiService.createUser(userData);
// Assert
expect(result).toEqual(expectedResponse);
expect(mockFetch).toHaveBeenCalledWith(
'https://api.example.com/users',
{
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify(userData)
}
);
});
it('should mock network errors', async () => {
// Arrange
mockFetch.mockRejectedValue(new Error('Network error'));
// Act & Assert
await expect(apiService.fetchUserData('user-123'))
.rejects
.toThrow('Network error');
});
});
// β
Mocking async operations with cleanup
describe('Async Operations with Cleanup', () => {
class ResourceManager {
private resources: Map<string, any> = new Map();
async acquireResource(id: string): Promise<{
id: string;
data: any;
release: () => Promise<void>;
}> {
// Simulate async resource acquisition
await new Promise(resolve => setTimeout(resolve, 50));
const resource = {
id,
data: `resource-data-${id}`,
acquired: new Date()
};
this.resources.set(id, resource);
return {
id,
data: resource.data,
release: async () => {
await new Promise(resolve => setTimeout(resolve, 25));
this.resources.delete(id);
}
};
}
getActiveResources(): string[] {
return Array.from(this.resources.keys());
}
}
let resourceManager: ResourceManager;
let mockSetTimeout: jest.SpyInstance;
let mockClearTimeout: jest.SpyInstance;
beforeEach(() => {
resourceManager = new ResourceManager();
// Mock timers for controlled testing
jest.useFakeTimers();
mockSetTimeout = jest.spyOn(global, 'setTimeout');
mockClearTimeout = jest.spyOn(global, 'clearTimeout');
});
afterEach(() => {
jest.useRealTimers();
jest.restoreAllMocks();
});
it('should handle resource acquisition and cleanup', async () => {
// Act
const resourcePromise = resourceManager.acquireResource('test-resource');
// Fast-forward timers to complete acquisition
jest.advanceTimersByTime(50);
const resource = await resourcePromise;
// Assert
expect(resource.id).toBe('test-resource');
expect(resource.data).toBe('resource-data-test-resource');
expect(resourceManager.getActiveResources()).toContain('test-resource');
// Act - release resource
const releasePromise = resource.release();
// Fast-forward timers to complete release
jest.advanceTimersByTime(25);
await releasePromise;
// Assert
expect(resourceManager.getActiveResources()).not.toContain('test-resource');
});
it('should handle multiple resources concurrently', async () => {
// Act - acquire multiple resources
const resource1Promise = resourceManager.acquireResource('resource-1');
const resource2Promise = resourceManager.acquireResource('resource-2');
const resource3Promise = resourceManager.acquireResource('resource-3');
// Fast-forward timers
jest.advanceTimersByTime(50);
const [resource1, resource2, resource3] = await Promise.all([
resource1Promise,
resource2Promise,
resource3Promise
]);
// Assert
expect(resourceManager.getActiveResources()).toEqual(
expect.arrayContaining(['resource-1', 'resource-2', 'resource-3'])
);
// Act - release all resources concurrently
const releasePromises = [
resource1.release(),
resource2.release(),
resource3.release()
];
jest.advanceTimersByTime(25);
await Promise.all(releasePromises);
// Assert
expect(resourceManager.getActiveResources()).toEqual([]);
});
});
});
// Performance testing for async operations
describe('Async Performance Testing', () => {
it('should measure async operation performance', async () => {
// Arrange
const operations = Array.from({ length: 100 }, (_, i) => i);
const asyncOperation = async (value: number): Promise<number> => {
// Simulate variable processing time
await new Promise(resolve => setTimeout(resolve, Math.random() * 10));
return value * 2;
};
// Act
const startTime = Date.now();
// Test sequential processing
const sequentialResults: number[] = [];
for (const op of operations) {
sequentialResults.push(await asyncOperation(op));
}
const sequentialTime = Date.now() - startTime;
// Test parallel processing
const parallelStartTime = Date.now();
const parallelResults = await Promise.all(
operations.map(op => asyncOperation(op))
);
const parallelTime = Date.now() - parallelStartTime;
// Assert
expect(sequentialResults).toEqual(parallelResults);
expect(parallelTime).toBeLessThan(sequentialTime);
// Performance should be significantly better for parallel execution
expect(parallelTime).toBeLessThan(sequentialTime * 0.5);
}, 10000); // Extended timeout for performance test
it('should handle async operation batching', async () => {
// Arrange
const batchSize = 5;
const totalOperations = 20;
const operations = Array.from({ length: totalOperations }, (_, i) => i);
const batchProcessor = async (batch: number[]): Promise<number[]> => {
// Simulate batch processing
await new Promise(resolve => setTimeout(resolve, 50));
return batch.map(n => n * 2);
};
// Act
const batches: number[][] = [];
for (let i = 0; i < operations.length; i += batchSize) {
batches.push(operations.slice(i, i + batchSize));
}
const results = await Promise.all(
batches.map(batch => batchProcessor(batch))
);
const flatResults = results.flat();
// Assert
expect(flatResults).toHaveLength(totalOperations);
expect(flatResults).toEqual(operations.map(n => n * 2));
expect(batches).toHaveLength(Math.ceil(totalOperations / batchSize));
});
});
π Testing Error Handling in Async Code
π¨ Comprehensive Error Testing Patterns
Error handling in async code requires testing various failure scenarios and recovery mechanisms.
// π Comprehensive async error handling testing
describe('Async Error Handling', () => {
// β
Testing promise rejection patterns
describe('Promise Rejection Testing', () => {
class AsyncValidator {
async validateEmail(email: string): Promise<boolean> {
if (!email) {
throw new Error('Email is required');
}
if (!email.includes('@')) {
throw new Error('Invalid email format');
}
// Simulate async validation
await new Promise(resolve => setTimeout(resolve, 100));
return true;
}
async validateUser(user: {
email: string;
age: number;
name: string;
}): Promise<void> {
const errors: string[] = [];
try {
await this.validateEmail(user.email);
} catch (error) {
errors.push((error as Error).message);
}
if (user.age < 13) {
errors.push('User must be at least 13 years old');
}
if (!user.name.trim()) {
errors.push('Name is required');
}
if (errors.length > 0) {
throw new Error(`Validation failed: ${errors.join(', ')}`);
}
}
async validateWithTimeout<T>(
validator: () => Promise<T>,
timeoutMs: number
): Promise<T> {
return Promise.race([
validator(),
new Promise<never>((_, reject) => {
setTimeout(() => {
reject(new Error('Validation timeout'));
}, timeoutMs);
})
]);
}
}
let validator: AsyncValidator;
beforeEach(() => {
validator = new AsyncValidator();
});
it('should handle basic promise rejection', async () => {
// Act & Assert
await expect(validator.validateEmail(''))
.rejects
.toThrow('Email is required');
await expect(validator.validateEmail('invalid-email'))
.rejects
.toThrow('Invalid email format');
});
it('should handle complex validation errors', async () => {
// Arrange
const invalidUser = {
email: 'invalid',
age: 10,
name: ''
};
// Act & Assert
await expect(validator.validateUser(invalidUser))
.rejects
.toThrow('Validation failed: Invalid email format, User must be at least 13 years old, Name is required');
});
it('should handle timeout errors', async () => {
// Arrange
const slowValidator = () => new Promise<boolean>(resolve => {
setTimeout(() => resolve(true), 200);
});
// Act & Assert
await expect(validator.validateWithTimeout(slowValidator, 100))
.rejects
.toThrow('Validation timeout');
});
it('should handle successful validation with timeout', async () => {
// Arrange
const fastValidator = () => new Promise<boolean>(resolve => {
setTimeout(() => resolve(true), 50);
});
// Act & Assert
await expect(validator.validateWithTimeout(fastValidator, 100))
.resolves
.toBe(true);
});
});
// β
Testing error propagation in async chains
describe('Error Propagation', () => {
class DataProcessor {
async fetchData(id: string): Promise<{ id: string; data: string }> {
if (id === 'invalid') {
throw new Error('Invalid ID');
}
return { id, data: `data-${id}` };
}
async processData(data: { id: string; data: string }): Promise<{
id: string;
processedData: string;
}> {
if (data.data.includes('error')) {
throw new Error('Processing failed');
}
return {
id: data.id,
processedData: data.data.toUpperCase()
};
}
async saveData(processedData: {
id: string;
processedData: string;
}): Promise<void> {
if (processedData.id === 'save-error') {
throw new Error('Save failed');
}
// Simulate save operation
await new Promise(resolve => setTimeout(resolve, 50));
}
async processWorkflow(id: string): Promise<void> {
try {
const data = await this.fetchData(id);
const processedData = await this.processData(data);
await this.saveData(processedData);
} catch (error) {
throw new Error(`Workflow failed: ${(error as Error).message}`);
}
}
async processWorkflowWithRetry(
id: string,
maxRetries: number = 3
): Promise<void> {
let lastError: Error;
for (let attempt = 0; attempt <= maxRetries; attempt++) {
try {
await this.processWorkflow(id);
return; // Success
} catch (error) {
lastError = error as Error;
if (attempt === maxRetries) {
throw new Error(`Max retries exceeded: ${lastError.message}`);
}
// Wait before retry
await new Promise(resolve => setTimeout(resolve, 100 * (attempt + 1)));
}
}
}
}
let processor: DataProcessor;
beforeEach(() => {
processor = new DataProcessor();
});
it('should propagate errors from fetch stage', async () => {
// Act & Assert
await expect(processor.processWorkflow('invalid'))
.rejects
.toThrow('Workflow failed: Invalid ID');
});
it('should propagate errors from processing stage', async () => {
// Arrange
jest.spyOn(processor, 'fetchData').mockResolvedValue({
id: 'test',
data: 'error-data'
});
// Act & Assert
await expect(processor.processWorkflow('test'))
.rejects
.toThrow('Workflow failed: Processing failed');
});
it('should propagate errors from save stage', async () => {
// Act & Assert
await expect(processor.processWorkflow('save-error'))
.rejects
.toThrow('Workflow failed: Save failed');
});
it('should handle successful workflow', async () => {
// Act & Assert
await expect(processor.processWorkflow('valid-id'))
.resolves
.toBeUndefined();
});
it('should handle retry mechanism on failure', async () => {
// Arrange
let attemptCount = 0;
jest.spyOn(processor, 'processWorkflow').mockImplementation(async () => {
attemptCount++;
if (attemptCount <= 2) {
throw new Error('Temporary failure');
}
// Success on third attempt
});
// Act & Assert
await expect(processor.processWorkflowWithRetry('test-id'))
.resolves
.toBeUndefined();
expect(attemptCount).toBe(3);
});
it('should fail after maximum retries', async () => {
// Arrange
jest.spyOn(processor, 'processWorkflow').mockRejectedValue(
new Error('Persistent failure')
);
// Act & Assert
await expect(processor.processWorkflowWithRetry('test-id', 2))
.rejects
.toThrow('Max retries exceeded: Workflow failed: Persistent failure');
});
});
// β
Testing async error boundaries
describe('Async Error Boundaries', () => {
class AsyncErrorBoundary {
private errorHandlers: Map<string, (error: Error) => void> = new Map();
private globalErrorHandler?: (error: Error) => void;
setGlobalErrorHandler(handler: (error: Error) => void): void {
this.globalErrorHandler = handler;
}
setErrorHandler(operationType: string, handler: (error: Error) => void): void {
this.errorHandlers.set(operationType, handler);
}
async executeWithErrorBoundary<T>(
operation: () => Promise<T>,
operationType: string
): Promise<T | null> {
try {
return await operation();
} catch (error) {
const specificHandler = this.errorHandlers.get(operationType);
if (specificHandler) {
specificHandler(error as Error);
} else if (this.globalErrorHandler) {
this.globalErrorHandler(error as Error);
} else {
throw error; // Re-throw if no handler
}
return null;
}
}
async executeBatch<T>(
operations: Array<{
operation: () => Promise<T>;
type: string;
}>
): Promise<Array<T | null>> {
const results = await Promise.allSettled(
operations.map(({ operation, type }) =>
this.executeWithErrorBoundary(operation, type)
)
);
return results.map(result =>
result.status === 'fulfilled' ? result.value : null
);
}
}
let errorBoundary: AsyncErrorBoundary;
let errorLogs: Array<{ type: string; message: string }>;
beforeEach(() => {
errorBoundary = new AsyncErrorBoundary();
errorLogs = [];
// Set up error handlers
errorBoundary.setGlobalErrorHandler((error) => {
errorLogs.push({ type: 'global', message: error.message });
});
errorBoundary.setErrorHandler('network', (error) => {
errorLogs.push({ type: 'network', message: error.message });
});
errorBoundary.setErrorHandler('validation', (error) => {
errorLogs.push({ type: 'validation', message: error.message });
});
});
it('should handle errors with specific handlers', async () => {
// Arrange
const networkOperation = async () => {
throw new Error('Network timeout');
};
const validationOperation = async () => {
throw new Error('Invalid input');
};
// Act
const networkResult = await errorBoundary.executeWithErrorBoundary(
networkOperation,
'network'
);
const validationResult = await errorBoundary.executeWithErrorBoundary(
validationOperation,
'validation'
);
// Assert
expect(networkResult).toBeNull();
expect(validationResult).toBeNull();
expect(errorLogs).toEqual([
{ type: 'network', message: 'Network timeout' },
{ type: 'validation', message: 'Invalid input' }
]);
});
it('should handle errors with global handler', async () => {
// Arrange
const unknownOperation = async () => {
throw new Error('Unknown error');
};
// Act
const result = await errorBoundary.executeWithErrorBoundary(
unknownOperation,
'unknown'
);
// Assert
expect(result).toBeNull();
expect(errorLogs).toEqual([
{ type: 'global', message: 'Unknown error' }
]);
});
it('should handle batch operations with mixed results', async () => {
// Arrange
const operations = [
{
operation: async () => 'success1',
type: 'success'
},
{
operation: async () => {
throw new Error('Network error');
},
type: 'network'
},
{
operation: async () => 'success2',
type: 'success'
},
{
operation: async () => {
throw new Error('Validation error');
},
type: 'validation'
}
];
// Act
const results = await errorBoundary.executeBatch(operations);
// Assert
expect(results).toEqual(['success1', null, 'success2', null]);
expect(errorLogs).toHaveLength(2);
expect(errorLogs[0]).toEqual({ type: 'network', message: 'Network error' });
expect(errorLogs[1]).toEqual({ type: 'validation', message: 'Validation error' });
});
});
// β
Testing custom error types
describe('Custom Error Types', () => {
class NetworkError extends Error {
constructor(message: string, public statusCode: number) {
super(message);
this.name = 'NetworkError';
}
}
class ValidationError extends Error {
constructor(message: string, public field: string) {
super(message);
this.name = 'ValidationError';
}
}
class BusinessLogicError extends Error {
constructor(message: string, public code: string) {
super(message);
this.name = 'BusinessLogicError';
}
}
class AsyncService {
async performOperation(data: {
id: string;
value: number;
}): Promise<string> {
// Validation
if (!data.id) {
throw new ValidationError('ID is required', 'id');
}
if (data.value < 0) {
throw new ValidationError('Value must be positive', 'value');
}
// Business logic
if (data.value > 1000) {
throw new BusinessLogicError('Value exceeds maximum allowed', 'VALUE_TOO_HIGH');
}
// Network operation
if (data.id === 'network-error') {
throw new NetworkError('Service unavailable', 503);
}
return `Processed: ${data.id} with value ${data.value}`;
}
async handleErrors<T>(operation: () => Promise<T>): Promise<{
result?: T;
error?: {
type: string;
message: string;
details?: any;
};
}> {
try {
const result = await operation();
return { result };
} catch (error) {
if (error instanceof NetworkError) {
return {
error: {
type: 'network',
message: error.message,
details: { statusCode: error.statusCode }
}
};
}
if (error instanceof ValidationError) {
return {
error: {
type: 'validation',
message: error.message,
details: { field: error.field }
}
};
}
if (error instanceof BusinessLogicError) {
return {
error: {
type: 'business',
message: error.message,
details: { code: error.code }
}
};
}
return {
error: {
type: 'unknown',
message: (error as Error).message
}
};
}
}
}
let service: AsyncService;
beforeEach(() => {
service = new AsyncService();
});
it('should throw and catch NetworkError', async () => {
// Act & Assert
await expect(service.performOperation({ id: 'network-error', value: 50 }))
.rejects
.toThrow(NetworkError);
try {
await service.performOperation({ id: 'network-error', value: 50 });
} catch (error) {
expect(error).toBeInstanceOf(NetworkError);
expect((error as NetworkError).statusCode).toBe(503);
}
});
it('should throw and catch ValidationError', async () => {
// Act & Assert
await expect(service.performOperation({ id: '', value: 50 }))
.rejects
.toThrow(ValidationError);
try {
await service.performOperation({ id: '', value: 50 });
} catch (error) {
expect(error).toBeInstanceOf(ValidationError);
expect((error as ValidationError).field).toBe('id');
}
});
it('should throw and catch BusinessLogicError', async () => {
// Act & Assert
await expect(service.performOperation({ id: 'test', value: 2000 }))
.rejects
.toThrow(BusinessLogicError);
try {
await service.performOperation({ id: 'test', value: 2000 });
} catch (error) {
expect(error).toBeInstanceOf(BusinessLogicError);
expect((error as BusinessLogicError).code).toBe('VALUE_TOO_HIGH');
}
});
it('should handle errors with typed error handling', async () => {
// Test network error
const networkResult = await service.handleErrors(() =>
service.performOperation({ id: 'network-error', value: 50 })
);
expect(networkResult.error).toEqual({
type: 'network',
message: 'Service unavailable',
details: { statusCode: 503 }
});
// Test validation error
const validationResult = await service.handleErrors(() =>
service.performOperation({ id: '', value: 50 })
);
expect(validationResult.error).toEqual({
type: 'validation',
message: 'ID is required',
details: { field: 'id' }
});
// Test business logic error
const businessResult = await service.handleErrors(() =>
service.performOperation({ id: 'test', value: 2000 })
);
expect(businessResult.error).toEqual({
type: 'business',
message: 'Value exceeds maximum allowed',
details: { code: 'VALUE_TOO_HIGH' }
});
// Test success case
const successResult = await service.handleErrors(() =>
service.performOperation({ id: 'test', value: 50 })
);
expect(successResult.result).toBe('Processed: test with value 50');
expect(successResult.error).toBeUndefined();
});
});
});
π― Best Practices and Advanced Patterns
π Production-Ready Async Testing
Here are the most important patterns for reliable async testing in production applications.
// π Production-ready async testing patterns
describe('Production Async Testing Best Practices', () => {
// β
Test isolation and cleanup
describe('Test Isolation', () => {
class ConnectionPool {
private connections: Map<string, any> = new Map();
private activeConnections = 0;
async getConnection(id: string): Promise<any> {
if (this.connections.has(id)) {
return this.connections.get(id);
}
const connection = {
id,
created: new Date(),
active: true
};
this.connections.set(id, connection);
this.activeConnections++;
return connection;
}
async releaseConnection(id: string): Promise<void> {
if (this.connections.has(id)) {
this.connections.delete(id);
this.activeConnections--;
}
}
async closeAllConnections(): Promise<void> {
for (const id of this.connections.keys()) {
await this.releaseConnection(id);
}
}
getStats(): { total: number; active: number } {
return {
total: this.connections.size,
active: this.activeConnections
};
}
}
let pool: ConnectionPool;
beforeEach(() => {
pool = new ConnectionPool();
});
afterEach(async () => {
// Always clean up async resources
await pool.closeAllConnections();
});
it('should properly isolate connection state between tests', async () => {
// Act
const conn1 = await pool.getConnection('test-1');
const conn2 = await pool.getConnection('test-2');
// Assert
expect(pool.getStats().total).toBe(2);
expect(pool.getStats().active).toBe(2);
});
it('should start with clean state', async () => {
// This test should have no connections from previous test
expect(pool.getStats().total).toBe(0);
expect(pool.getStats().active).toBe(0);
});
});
// β
Deterministic timing tests
describe('Deterministic Timing', () => {
class TaskScheduler {
private tasks: Array<{
id: string;
delay: number;
callback: () => void;
scheduled: Date;
}> = [];
schedule(id: string, delay: number, callback: () => void): void {
this.tasks.push({
id,
delay,
callback,
scheduled: new Date()
});
setTimeout(() => {
callback();
this.tasks = this.tasks.filter(t => t.id !== id);
}, delay);
}
getTasks(): Array<{ id: string; delay: number; scheduled: Date }> {
return this.tasks.map(({ id, delay, scheduled }) => ({
id,
delay,
scheduled
}));
}
clear(): void {
this.tasks = [];
}
}
let scheduler: TaskScheduler;
beforeEach(() => {
scheduler = new TaskScheduler();
jest.useFakeTimers();
});
afterEach(() => {
scheduler.clear();
jest.useRealTimers();
});
it('should execute tasks in correct order with fake timers', () => {
// Arrange
const executionOrder: string[] = [];
// Act
scheduler.schedule('task-1', 100, () => executionOrder.push('task-1'));
scheduler.schedule('task-2', 50, () => executionOrder.push('task-2'));
scheduler.schedule('task-3', 150, () => executionOrder.push('task-3'));
// Assert initial state
expect(executionOrder).toEqual([]);
expect(scheduler.getTasks()).toHaveLength(3);
// Fast-forward time
jest.advanceTimersByTime(50);
expect(executionOrder).toEqual(['task-2']);
jest.advanceTimersByTime(50);
expect(executionOrder).toEqual(['task-2', 'task-1']);
jest.advanceTimersByTime(50);
expect(executionOrder).toEqual(['task-2', 'task-1', 'task-3']);
});
it('should handle concurrent task scheduling', () => {
// Arrange
const results: string[] = [];
const taskCount = 10;
// Act - schedule multiple tasks simultaneously
for (let i = 0; i < taskCount; i++) {
scheduler.schedule(
`task-${i}`,
Math.random() * 100, // Random delay up to 100ms
() => results.push(`task-${i}`)
);
}
// Fast-forward all timers
jest.advanceTimersByTime(100);
// Assert
expect(results).toHaveLength(taskCount);
expect(scheduler.getTasks()).toHaveLength(0); // All tasks completed
});
});
// β
Comprehensive async integration testing
describe('Async Integration Testing', () => {
interface DatabaseService {
connect(): Promise<void>;
disconnect(): Promise<void>;
query<T>(sql: string, params?: any[]): Promise<T[]>;
}
interface CacheService {
get<T>(key: string): Promise<T | null>;
set<T>(key: string, value: T, ttl?: number): Promise<void>;
}
interface NotificationService {
sendNotification(userId: string, message: string): Promise<void>;
}
class UserManagementService {
constructor(
private db: DatabaseService,
private cache: CacheService,
private notifications: NotificationService
) {}
async createUserWorkflow(userData: {
name: string;
email: string;
}): Promise<{
user: { id: string; name: string; email: string };
cached: boolean;
notificationSent: boolean;
}> {
// Create user in database
const userId = `user-${Date.now()}`;
await this.db.query(
'INSERT INTO users (id, name, email) VALUES (?, ?, ?)',
[userId, userData.name, userData.email]
);
const user = { id: userId, ...userData };
// Parallel operations for better performance
const [cacheResult, notificationResult] = await Promise.allSettled([
this.cache.set(`user:${userId}`, user, 3600),
this.notifications.sendNotification(userId, `Welcome ${userData.name}!`)
]);
return {
user,
cached: cacheResult.status === 'fulfilled',
notificationSent: notificationResult.status === 'fulfilled'
};
}
async deleteUserWorkflow(userId: string): Promise<{
deleted: boolean;
cacheCleared: boolean;
notificationSent: boolean;
}> {
let deleted = false;
let cacheCleared = false;
let notificationSent = false;
try {
// Delete from database
await this.db.query('DELETE FROM users WHERE id = ?', [userId]);
deleted = true;
// Clear cache (non-critical)
try {
await this.cache.set(`user:${userId}`, null, 0);
cacheCleared = true;
} catch (error) {
console.warn('Failed to clear cache:', error);
}
// Send notification (non-critical)
try {
await this.notifications.sendNotification(userId, 'Account deleted');
notificationSent = true;
} catch (error) {
console.warn('Failed to send notification:', error);
}
} catch (error) {
throw new Error(`Failed to delete user: ${(error as Error).message}`);
}
return { deleted, cacheCleared, notificationSent };
}
}
let service: UserManagementService;
let mockDb: jest.Mocked<DatabaseService>;
let mockCache: jest.Mocked<CacheService>;
let mockNotifications: jest.Mocked<NotificationService>;
beforeEach(() => {
mockDb = {
connect: jest.fn(),
disconnect: jest.fn(),
query: jest.fn()
};
mockCache = {
get: jest.fn(),
set: jest.fn()
};
mockNotifications = {
sendNotification: jest.fn()
};
service = new UserManagementService(mockDb, mockCache, mockNotifications);
});
it('should handle successful user creation workflow', async () => {
// Arrange
const userData = {
name: 'John Doe',
email: '[email protected]'
};
mockDb.query.mockResolvedValue([]);
mockCache.set.mockResolvedValue();
mockNotifications.sendNotification.mockResolvedValue();
// Act
const result = await service.createUserWorkflow(userData);
// Assert
expect(result.user.name).toBe(userData.name);
expect(result.user.email).toBe(userData.email);
expect(result.user.id).toMatch(/^user-\d+$/);
expect(result.cached).toBe(true);
expect(result.notificationSent).toBe(true);
// Verify all services were called
expect(mockDb.query).toHaveBeenCalledWith(
'INSERT INTO users (id, name, email) VALUES (?, ?, ?)',
[result.user.id, userData.name, userData.email]
);
expect(mockCache.set).toHaveBeenCalledWith(
`user:${result.user.id}`,
result.user,
3600
);
expect(mockNotifications.sendNotification).toHaveBeenCalledWith(
result.user.id,
`Welcome ${userData.name}!`
);
});
it('should handle partial failures in user creation', async () => {
// Arrange
const userData = {
name: 'Jane Doe',
email: '[email protected]'
};
mockDb.query.mockResolvedValue([]);
mockCache.set.mockRejectedValue(new Error('Cache service down'));
mockNotifications.sendNotification.mockResolvedValue();
// Act
const result = await service.createUserWorkflow(userData);
// Assert
expect(result.user).toBeDefined();
expect(result.cached).toBe(false); // Cache failed
expect(result.notificationSent).toBe(true); // Notification succeeded
});
it('should handle database failure in user creation', async () => {
// Arrange
const userData = {
name: 'Bob Smith',
email: '[email protected]'
};
mockDb.query.mockRejectedValue(new Error('Database connection failed'));
// Act & Assert
await expect(service.createUserWorkflow(userData))
.rejects
.toThrow('Database connection failed');
// Verify no cache or notification calls were made
expect(mockCache.set).not.toHaveBeenCalled();
expect(mockNotifications.sendNotification).not.toHaveBeenCalled();
});
it('should handle user deletion workflow', async () => {
// Arrange
const userId = 'user-123';
mockDb.query.mockResolvedValue([]);
mockCache.set.mockResolvedValue();
mockNotifications.sendNotification.mockResolvedValue();
// Act
const result = await service.deleteUserWorkflow(userId);
// Assert
expect(result.deleted).toBe(true);
expect(result.cacheCleared).toBe(true);
expect(result.notificationSent).toBe(true);
expect(mockDb.query).toHaveBeenCalledWith(
'DELETE FROM users WHERE id = ?',
[userId]
);
expect(mockCache.set).toHaveBeenCalledWith(`user:${userId}`, null, 0);
expect(mockNotifications.sendNotification).toHaveBeenCalledWith(
userId,
'Account deleted'
);
});
it('should handle graceful degradation in deletion workflow', async () => {
// Arrange
const userId = 'user-123';
mockDb.query.mockResolvedValue([]);
mockCache.set.mockRejectedValue(new Error('Cache error'));
mockNotifications.sendNotification.mockRejectedValue(new Error('Notification error'));
// Act
const result = await service.deleteUserWorkflow(userId);
// Assert
expect(result.deleted).toBe(true); // Main operation succeeded
expect(result.cacheCleared).toBe(false); // Cache operation failed
expect(result.notificationSent).toBe(false); // Notification failed
});
});
// β
Performance and load testing
describe('Async Performance Testing', () => {
it('should handle high concurrency load', async () => {
// Arrange
const concurrentOperations = 100;
const operations: Promise<number>[] = [];
const asyncOperation = async (id: number): Promise<number> => {
// Simulate variable processing time
await new Promise(resolve =>
setTimeout(resolve, Math.random() * 10)
);
return id * 2;
};
// Act
const startTime = Date.now();
for (let i = 0; i < concurrentOperations; i++) {
operations.push(asyncOperation(i));
}
const results = await Promise.all(operations);
const endTime = Date.now();
// Assert
expect(results).toHaveLength(concurrentOperations);
expect(results[0]).toBe(0);
expect(results[99]).toBe(198);
// Should complete within reasonable time for concurrent execution
expect(endTime - startTime).toBeLessThan(100);
}, 10000);
it('should handle memory pressure correctly', async () => {
// Arrange
const largeDataOperations = 50;
const operations: Promise<string>[] = [];
const createLargeData = async (id: number): Promise<string> => {
// Create large string data
const largeString = 'x'.repeat(10000);
// Simulate async processing
await new Promise(resolve => setTimeout(resolve, 1));
return `${id}-${largeString}`;
};
// Act
for (let i = 0; i < largeDataOperations; i++) {
operations.push(createLargeData(i));
}
const results = await Promise.all(operations);
// Assert
expect(results).toHaveLength(largeDataOperations);
results.forEach((result, index) => {
expect(result).toMatch(new RegExp(`^${index}-x{10000}$`));
});
});
});
});
π Conclusion
Congratulations! Youβve mastered the art of testing asynchronous TypeScript code! π―
π Key Takeaways
- Promise Testing: Use async/await in tests for clean, readable async test code
- Callback Testing: Use
done
callback or promisify callback-based functions - Timing Control: Use Jestβs fake timers for deterministic timing tests
- Error Handling: Test both success and failure scenarios comprehensively
- Mocking: Mock async dependencies with realistic behaviors and timing
- Race Conditions: Test concurrent operations and resource contention
- Performance: Test async performance and memory usage under load
- Integration: Test complete async workflows end-to-end
π Next Steps
- Testing Classes: Learn OOP testing patterns for TypeScript classes
- Testing React Components: Master component testing with async operations
- Testing Hooks: Test custom React hooks with async behavior
- Integration Testing: Build comprehensive integration test suites
- E2E Testing: Test complete user workflows with async operations
You now have the skills to test any asynchronous TypeScript code with confidence, ensuring your async operations work correctly under all conditions! π