Prerequisites
- Understanding of TypeScript fundamentals and testing concepts 📝
- Basic knowledge of Jest testing framework ⚡
- Familiarity with object-oriented programming in TypeScript 💻
What you'll learn
- Write comprehensive unit tests for TypeScript functions and classes 🎯
- Master testing patterns and best practices for type-safe code 🏗️
- Handle edge cases, error conditions, and async operations in tests 🐛
- Create maintainable and reliable test suites ✨
🎯 Introduction
Welcome to the workshop of TypeScript unit testing mastery! 🧪 If writing TypeScript code were like being a skilled craftsperson creating precision instruments, then writing unit tests would be like having a quality control lab where you test every component, every function, and every edge case to ensure your instruments work perfectly under all conditions - from the gentlest touch to the most demanding use!
Unit testing is the foundation of reliable software development. In TypeScript, unit tests not only verify that your code works correctly but also serve as living documentation of how your functions and classes are intended to be used. They provide confidence for refactoring, catch regressions early, and help you design better APIs.
By the end of this tutorial, you’ll be a master of writing comprehensive, maintainable unit tests that leverage TypeScript’s type system to create robust test suites. You’ll learn testing patterns, best practices, and how to handle complex scenarios with confidence. Let’s build some bulletproof tests! 🌟
📚 Unit Testing Fundamentals in TypeScript
🤔 What Makes a Good Unit Test?
A good unit test is fast, isolated, repeatable, self-validating, and timely. In TypeScript, we can leverage the type system to make our tests even more robust.
// 🌟 Setting up a comprehensive testing environment
// First, let's create a Calculator class to test
class Calculator {
private history: CalculationHistory[] = [];
// Basic arithmetic operations
add(a: number, b: number): number {
const result = a + b;
this.recordCalculation('add', [a, b], result);
return result;
}
subtract(a: number, b: number): number {
const result = a - b;
this.recordCalculation('subtract', [a, b], result);
return result;
}
multiply(a: number, b: number): number {
const result = a * b;
this.recordCalculation('multiply', [a, b], result);
return result;
}
divide(a: number, b: number): number {
if (b === 0) {
throw new Error('Division by zero is not allowed');
}
const result = a / b;
this.recordCalculation('divide', [a, b], result);
return result;
}
// Advanced operations
power(base: number, exponent: number): number {
if (exponent < 0) {
throw new Error('Negative exponents not supported');
}
const result = Math.pow(base, exponent);
this.recordCalculation('power', [base, exponent], result);
return result;
}
sqrt(value: number): number {
if (value < 0) {
throw new Error('Cannot calculate square root of negative number');
}
const result = Math.sqrt(value);
this.recordCalculation('sqrt', [value], result);
return result;
}
// Utility methods
clear(): void {
this.history = [];
}
getHistory(): readonly CalculationHistory[] {
return [...this.history];
}
getLastResult(): number | null {
const lastCalculation = this.history[this.history.length - 1];
return lastCalculation ? lastCalculation.result : null;
}
private recordCalculation(operation: string, operands: number[], result: number): void {
this.history.push({
operation,
operands,
result,
timestamp: new Date()
});
}
}
interface CalculationHistory {
operation: string;
operands: number[];
result: number;
timestamp: Date;
}
// 🧪 Comprehensive unit tests demonstrating best practices
describe('Calculator', () => {
let calculator: Calculator;
// Setup before each test - ensures test isolation
beforeEach(() => {
calculator = new Calculator();
});
// Group related tests together
describe('Basic Arithmetic Operations', () => {
describe('add', () => {
it('should add two positive numbers correctly', () => {
// Arrange
const a = 5;
const b = 3;
const expected = 8;
// Act
const result = calculator.add(a, b);
// Assert
expect(result).toBe(expected);
});
it('should add negative numbers correctly', () => {
expect(calculator.add(-5, -3)).toBe(-8);
expect(calculator.add(-5, 3)).toBe(-2);
expect(calculator.add(5, -3)).toBe(2);
});
it('should handle zero correctly', () => {
expect(calculator.add(0, 5)).toBe(5);
expect(calculator.add(5, 0)).toBe(5);
expect(calculator.add(0, 0)).toBe(0);
});
it('should handle decimal numbers correctly', () => {
expect(calculator.add(0.1, 0.2)).toBeCloseTo(0.3);
expect(calculator.add(1.5, 2.5)).toBe(4);
});
it('should handle large numbers', () => {
const largeNumber = Number.MAX_SAFE_INTEGER - 1;
expect(calculator.add(largeNumber, 1)).toBe(Number.MAX_SAFE_INTEGER);
});
});
describe('subtract', () => {
it('should subtract two positive numbers correctly', () => {
expect(calculator.subtract(10, 3)).toBe(7);
});
it('should handle negative results', () => {
expect(calculator.subtract(3, 10)).toBe(-7);
});
it('should handle subtracting zero', () => {
expect(calculator.subtract(5, 0)).toBe(5);
});
it('should handle subtracting from zero', () => {
expect(calculator.subtract(0, 5)).toBe(-5);
});
});
describe('multiply', () => {
it('should multiply two positive numbers correctly', () => {
expect(calculator.multiply(4, 5)).toBe(20);
});
it('should handle multiplication by zero', () => {
expect(calculator.multiply(5, 0)).toBe(0);
expect(calculator.multiply(0, 5)).toBe(0);
});
it('should handle multiplication by one', () => {
expect(calculator.multiply(7, 1)).toBe(7);
expect(calculator.multiply(1, 7)).toBe(7);
});
it('should handle negative numbers', () => {
expect(calculator.multiply(-3, 4)).toBe(-12);
expect(calculator.multiply(-3, -4)).toBe(12);
});
});
describe('divide', () => {
it('should divide two positive numbers correctly', () => {
expect(calculator.divide(15, 3)).toBe(5);
});
it('should handle decimal results', () => {
expect(calculator.divide(10, 3)).toBeCloseTo(3.333333);
});
it('should handle dividing zero', () => {
expect(calculator.divide(0, 5)).toBe(0);
});
it('should throw error when dividing by zero', () => {
expect(() => calculator.divide(5, 0)).toThrow('Division by zero is not allowed');
});
it('should handle negative numbers', () => {
expect(calculator.divide(-10, 2)).toBe(-5);
expect(calculator.divide(-10, -2)).toBe(5);
});
});
});
describe('Advanced Operations', () => {
describe('power', () => {
it('should calculate power correctly', () => {
expect(calculator.power(2, 3)).toBe(8);
expect(calculator.power(5, 2)).toBe(25);
});
it('should handle power of zero', () => {
expect(calculator.power(5, 0)).toBe(1);
expect(calculator.power(0, 0)).toBe(1); // 0^0 = 1 by convention
});
it('should handle power of one', () => {
expect(calculator.power(7, 1)).toBe(7);
});
it('should throw error for negative exponents', () => {
expect(() => calculator.power(2, -1)).toThrow('Negative exponents not supported');
});
});
describe('sqrt', () => {
it('should calculate square root correctly', () => {
expect(calculator.sqrt(9)).toBe(3);
expect(calculator.sqrt(16)).toBe(4);
expect(calculator.sqrt(25)).toBe(5);
});
it('should handle square root of zero', () => {
expect(calculator.sqrt(0)).toBe(0);
});
it('should handle square root of one', () => {
expect(calculator.sqrt(1)).toBe(1);
});
it('should handle decimal results', () => {
expect(calculator.sqrt(2)).toBeCloseTo(1.414213);
});
it('should throw error for negative numbers', () => {
expect(() => calculator.sqrt(-1)).toThrow('Cannot calculate square root of negative number');
});
});
});
describe('History and State Management', () => {
it('should record calculation history', () => {
calculator.add(2, 3);
calculator.multiply(4, 5);
const history = calculator.getHistory();
expect(history).toHaveLength(2);
expect(history[0].operation).toBe('add');
expect(history[0].operands).toEqual([2, 3]);
expect(history[0].result).toBe(5);
expect(history[0].timestamp).toBeInstanceOf(Date);
expect(history[1].operation).toBe('multiply');
expect(history[1].operands).toEqual([4, 5]);
expect(history[1].result).toBe(20);
});
it('should return readonly history', () => {
calculator.add(1, 1);
const history = calculator.getHistory();
// TypeScript should prevent this, but let's verify at runtime
expect(() => {
(history as any).push({ operation: 'fake', operands: [], result: 0, timestamp: new Date() });
}).toThrow();
});
it('should clear history correctly', () => {
calculator.add(1, 1);
calculator.multiply(2, 2);
expect(calculator.getHistory()).toHaveLength(2);
calculator.clear();
expect(calculator.getHistory()).toHaveLength(0);
});
it('should get last result correctly', () => {
expect(calculator.getLastResult()).toBeNull();
calculator.add(2, 3);
expect(calculator.getLastResult()).toBe(5);
calculator.multiply(4, 5);
expect(calculator.getLastResult()).toBe(20);
});
it('should handle multiple operations in sequence', () => {
const result1 = calculator.add(10, 5); // 15
const result2 = calculator.subtract(20, 8); // 12
const result3 = calculator.multiply(3, 4); // 12
const result4 = calculator.divide(21, 7); // 3
expect(result1).toBe(15);
expect(result2).toBe(12);
expect(result3).toBe(12);
expect(result4).toBe(3);
const history = calculator.getHistory();
expect(history).toHaveLength(4);
expect(calculator.getLastResult()).toBe(3);
});
});
});
// 📊 More complex example: Testing a User Management System
interface User {
id: string;
email: string;
name: string;
age: number;
isActive: boolean;
roles: UserRole[];
createdAt: Date;
lastLoginAt?: Date;
}
interface UserRole {
name: string;
permissions: string[];
}
interface CreateUserData {
email: string;
name: string;
age: number;
roles?: UserRole[];
}
interface UpdateUserData {
name?: string;
age?: number;
isActive?: boolean;
roles?: UserRole[];
}
class UserManager {
private users: Map<string, User> = new Map();
private emailIndex: Map<string, string> = new Map(); // email -> userId
createUser(userData: CreateUserData): User {
// Validation
if (!this.isValidEmail(userData.email)) {
throw new Error('Invalid email format');
}
if (this.emailIndex.has(userData.email)) {
throw new Error('Email already exists');
}
if (userData.age < 0 || userData.age > 150) {
throw new Error('Invalid age');
}
if (userData.name.trim().length === 0) {
throw new Error('Name cannot be empty');
}
// Create user
const user: User = {
id: this.generateId(),
email: userData.email.toLowerCase(),
name: userData.name.trim(),
age: userData.age,
isActive: true,
roles: userData.roles || [],
createdAt: new Date()
};
// Store user
this.users.set(user.id, user);
this.emailIndex.set(user.email, user.id);
return user;
}
getUserById(id: string): User | null {
return this.users.get(id) || null;
}
getUserByEmail(email: string): User | null {
const userId = this.emailIndex.get(email.toLowerCase());
return userId ? this.users.get(userId) || null : null;
}
updateUser(id: string, updates: UpdateUserData): User {
const user = this.users.get(id);
if (!user) {
throw new Error('User not found');
}
// Validate updates
if (updates.age !== undefined && (updates.age < 0 || updates.age > 150)) {
throw new Error('Invalid age');
}
if (updates.name !== undefined && updates.name.trim().length === 0) {
throw new Error('Name cannot be empty');
}
// Apply updates
const updatedUser: User = {
...user,
...(updates.name !== undefined && { name: updates.name.trim() }),
...(updates.age !== undefined && { age: updates.age }),
...(updates.isActive !== undefined && { isActive: updates.isActive }),
...(updates.roles !== undefined && { roles: updates.roles })
};
this.users.set(id, updatedUser);
return updatedUser;
}
deleteUser(id: string): boolean {
const user = this.users.get(id);
if (!user) {
return false;
}
this.users.delete(id);
this.emailIndex.delete(user.email);
return true;
}
recordLogin(id: string): void {
const user = this.users.get(id);
if (!user) {
throw new Error('User not found');
}
const updatedUser: User = {
...user,
lastLoginAt: new Date()
};
this.users.set(id, updatedUser);
}
getActiveUsers(): User[] {
return Array.from(this.users.values()).filter(user => user.isActive);
}
getUsersByRole(roleName: string): User[] {
return Array.from(this.users.values()).filter(user =>
user.roles.some(role => role.name === roleName)
);
}
getUserCount(): number {
return this.users.size;
}
private generateId(): string {
return `user_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`;
}
private isValidEmail(email: string): boolean {
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
return emailRegex.test(email);
}
}
describe('UserManager', () => {
let userManager: UserManager;
beforeEach(() => {
userManager = new UserManager();
});
describe('createUser', () => {
it('should create a user with valid data', () => {
const userData: CreateUserData = {
email: '[email protected]',
name: 'John Doe',
age: 30
};
const user = userManager.createUser(userData);
expect(user).toMatchObject({
email: '[email protected]',
name: 'John Doe',
age: 30,
isActive: true,
roles: []
});
expect(user.id).toBeDefined();
expect(user.createdAt).toBeInstanceOf(Date);
});
it('should create a user with roles', () => {
const userData: CreateUserData = {
email: '[email protected]',
name: 'Admin User',
age: 35,
roles: [
{ name: 'admin', permissions: ['read', 'write', 'delete'] }
]
};
const user = userManager.createUser(userData);
expect(user.roles).toHaveLength(1);
expect(user.roles[0]).toMatchObject({
name: 'admin',
permissions: ['read', 'write', 'delete']
});
});
it('should normalize email to lowercase', () => {
const userData: CreateUserData = {
email: '[email protected]',
name: 'John Doe',
age: 30
};
const user = userManager.createUser(userData);
expect(user.email).toBe('[email protected]');
});
it('should trim whitespace from name', () => {
const userData: CreateUserData = {
email: '[email protected]',
name: ' Jane Smith ',
age: 25
};
const user = userManager.createUser(userData);
expect(user.name).toBe('Jane Smith');
});
it('should throw error for invalid email', () => {
const userData: CreateUserData = {
email: 'invalid-email',
name: 'John Doe',
age: 30
};
expect(() => userManager.createUser(userData)).toThrow('Invalid email format');
});
it('should throw error for duplicate email', () => {
const userData: CreateUserData = {
email: '[email protected]',
name: 'John Doe',
age: 30
};
userManager.createUser(userData);
expect(() => userManager.createUser(userData)).toThrow('Email already exists');
});
it('should throw error for invalid age', () => {
const userData: CreateUserData = {
email: '[email protected]',
name: 'John Doe',
age: -1
};
expect(() => userManager.createUser(userData)).toThrow('Invalid age');
const userData2: CreateUserData = {
email: '[email protected]',
name: 'Jane Doe',
age: 200
};
expect(() => userManager.createUser(userData2)).toThrow('Invalid age');
});
it('should throw error for empty name', () => {
const userData: CreateUserData = {
email: '[email protected]',
name: ' ',
age: 30
};
expect(() => userManager.createUser(userData)).toThrow('Name cannot be empty');
});
});
describe('getUserById', () => {
it('should return user when found', () => {
const userData: CreateUserData = {
email: '[email protected]',
name: 'John Doe',
age: 30
};
const createdUser = userManager.createUser(userData);
const foundUser = userManager.getUserById(createdUser.id);
expect(foundUser).toEqual(createdUser);
});
it('should return null when user not found', () => {
const user = userManager.getUserById('non-existent-id');
expect(user).toBeNull();
});
});
describe('getUserByEmail', () => {
it('should return user when found', () => {
const userData: CreateUserData = {
email: '[email protected]',
name: 'John Doe',
age: 30
};
const createdUser = userManager.createUser(userData);
const foundUser = userManager.getUserByEmail('[email protected]');
expect(foundUser).toEqual(createdUser);
});
it('should be case insensitive', () => {
const userData: CreateUserData = {
email: '[email protected]',
name: 'John Doe',
age: 30
};
userManager.createUser(userData);
const foundUser = userManager.getUserByEmail('[email protected]');
expect(foundUser).not.toBeNull();
expect(foundUser!.email).toBe('[email protected]');
});
it('should return null when user not found', () => {
const user = userManager.getUserByEmail('[email protected]');
expect(user).toBeNull();
});
});
describe('updateUser', () => {
let createdUser: User;
beforeEach(() => {
const userData: CreateUserData = {
email: '[email protected]',
name: 'John Doe',
age: 30
};
createdUser = userManager.createUser(userData);
});
it('should update user name', () => {
const updatedUser = userManager.updateUser(createdUser.id, {
name: 'John Smith'
});
expect(updatedUser.name).toBe('John Smith');
expect(updatedUser.age).toBe(30); // unchanged
});
it('should update user age', () => {
const updatedUser = userManager.updateUser(createdUser.id, {
age: 35
});
expect(updatedUser.age).toBe(35);
expect(updatedUser.name).toBe('John Doe'); // unchanged
});
it('should update user active status', () => {
const updatedUser = userManager.updateUser(createdUser.id, {
isActive: false
});
expect(updatedUser.isActive).toBe(false);
});
it('should update user roles', () => {
const newRoles: UserRole[] = [
{ name: 'editor', permissions: ['read', 'write'] }
];
const updatedUser = userManager.updateUser(createdUser.id, {
roles: newRoles
});
expect(updatedUser.roles).toEqual(newRoles);
});
it('should trim whitespace from updated name', () => {
const updatedUser = userManager.updateUser(createdUser.id, {
name: ' John Smith '
});
expect(updatedUser.name).toBe('John Smith');
});
it('should throw error when user not found', () => {
expect(() => userManager.updateUser('non-existent-id', { name: 'New Name' }))
.toThrow('User not found');
});
it('should throw error for invalid age update', () => {
expect(() => userManager.updateUser(createdUser.id, { age: -1 }))
.toThrow('Invalid age');
});
it('should throw error for empty name update', () => {
expect(() => userManager.updateUser(createdUser.id, { name: ' ' }))
.toThrow('Name cannot be empty');
});
});
describe('deleteUser', () => {
it('should delete existing user and return true', () => {
const userData: CreateUserData = {
email: '[email protected]',
name: 'John Doe',
age: 30
};
const user = userManager.createUser(userData);
const deleted = userManager.deleteUser(user.id);
expect(deleted).toBe(true);
expect(userManager.getUserById(user.id)).toBeNull();
expect(userManager.getUserByEmail(user.email)).toBeNull();
});
it('should return false when user does not exist', () => {
const deleted = userManager.deleteUser('non-existent-id');
expect(deleted).toBe(false);
});
});
describe('recordLogin', () => {
it('should record login time for existing user', () => {
const userData: CreateUserData = {
email: '[email protected]',
name: 'John Doe',
age: 30
};
const user = userManager.createUser(userData);
expect(user.lastLoginAt).toBeUndefined();
userManager.recordLogin(user.id);
const updatedUser = userManager.getUserById(user.id);
expect(updatedUser!.lastLoginAt).toBeInstanceOf(Date);
expect(updatedUser!.lastLoginAt!.getTime()).toBeCloseTo(Date.now(), -2); // within 10ms
});
it('should throw error when user not found', () => {
expect(() => userManager.recordLogin('non-existent-id'))
.toThrow('User not found');
});
});
describe('getActiveUsers', () => {
it('should return only active users', () => {
const user1 = userManager.createUser({
email: '[email protected]',
name: 'User 1',
age: 25
});
const user2 = userManager.createUser({
email: '[email protected]',
name: 'User 2',
age: 30
});
// Deactivate user2
userManager.updateUser(user2.id, { isActive: false });
const activeUsers = userManager.getActiveUsers();
expect(activeUsers).toHaveLength(1);
expect(activeUsers[0].id).toBe(user1.id);
});
it('should return empty array when no active users', () => {
const user = userManager.createUser({
email: '[email protected]',
name: 'User',
age: 25
});
userManager.updateUser(user.id, { isActive: false });
const activeUsers = userManager.getActiveUsers();
expect(activeUsers).toHaveLength(0);
});
});
describe('getUsersByRole', () => {
it('should return users with specified role', () => {
const adminRole: UserRole = {
name: 'admin',
permissions: ['read', 'write', 'delete']
};
const editorRole: UserRole = {
name: 'editor',
permissions: ['read', 'write']
};
const user1 = userManager.createUser({
email: '[email protected]',
name: 'Admin User',
age: 35,
roles: [adminRole]
});
const user2 = userManager.createUser({
email: '[email protected]',
name: 'Editor User',
age: 28,
roles: [editorRole]
});
const user3 = userManager.createUser({
email: '[email protected]',
name: 'Both Roles User',
age: 32,
roles: [adminRole, editorRole]
});
const admins = userManager.getUsersByRole('admin');
const editors = userManager.getUsersByRole('editor');
expect(admins).toHaveLength(2);
expect(admins.map(u => u.id)).toContain(user1.id);
expect(admins.map(u => u.id)).toContain(user3.id);
expect(editors).toHaveLength(2);
expect(editors.map(u => u.id)).toContain(user2.id);
expect(editors.map(u => u.id)).toContain(user3.id);
});
it('should return empty array when no users have the role', () => {
userManager.createUser({
email: '[email protected]',
name: 'Regular User',
age: 25
});
const admins = userManager.getUsersByRole('admin');
expect(admins).toHaveLength(0);
});
});
describe('getUserCount', () => {
it('should return correct user count', () => {
expect(userManager.getUserCount()).toBe(0);
userManager.createUser({
email: '[email protected]',
name: 'User 1',
age: 25
});
expect(userManager.getUserCount()).toBe(1);
const user2 = userManager.createUser({
email: '[email protected]',
name: 'User 2',
age: 30
});
expect(userManager.getUserCount()).toBe(2);
userManager.deleteUser(user2.id);
expect(userManager.getUserCount()).toBe(1);
});
});
});
🎨 Advanced Testing Patterns
// 🎯 Testing Async Operations and Error Handling
class ApiService {
constructor(private baseUrl: string, private timeout: number = 5000) {}
async fetchUser(id: string): Promise<User> {
if (!id) {
throw new Error('User ID is required');
}
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), this.timeout);
try {
const response = await fetch(`${this.baseUrl}/users/${id}`, {
signal: controller.signal
});
clearTimeout(timeoutId);
if (!response.ok) {
if (response.status === 404) {
throw new Error(`User with ID ${id} not found`);
}
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
}
const userData = await response.json();
return this.validateUserData(userData);
} catch (error) {
clearTimeout(timeoutId);
if (error instanceof Error && error.name === 'AbortError') {
throw new Error('Request timeout');
}
throw error;
}
}
async createUser(userData: CreateUserData): Promise<User> {
const response = await fetch(`${this.baseUrl}/users`, {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify(userData)
});
if (!response.ok) {
const errorData = await response.json().catch(() => ({}));
throw new Error(errorData.message || `HTTP ${response.status}: ${response.statusText}`);
}
const user = await response.json();
return this.validateUserData(user);
}
async updateUser(id: string, updates: UpdateUserData): Promise<User> {
const response = await fetch(`${this.baseUrl}/users/${id}`, {
method: 'PATCH',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify(updates)
});
if (!response.ok) {
throw new Error(`Failed to update user: ${response.statusText}`);
}
const user = await response.json();
return this.validateUserData(user);
}
async deleteUser(id: string): Promise<void> {
const response = await fetch(`${this.baseUrl}/users/${id}`, {
method: 'DELETE'
});
if (!response.ok && response.status !== 404) {
throw new Error(`Failed to delete user: ${response.statusText}`);
}
}
private validateUserData(data: any): User {
if (!data || typeof data !== 'object') {
throw new Error('Invalid user data');
}
if (typeof data.id !== 'string' || !data.id) {
throw new Error('Invalid user ID');
}
if (typeof data.email !== 'string' || !data.email) {
throw new Error('Invalid user email');
}
if (typeof data.name !== 'string' || !data.name) {
throw new Error('Invalid user name');
}
if (typeof data.age !== 'number' || data.age < 0) {
throw new Error('Invalid user age');
}
return data as User;
}
}
// Testing async operations with proper mocking
describe('ApiService', () => {
let apiService: ApiService;
let mockFetch: jest.Mock;
beforeEach(() => {
// Mock fetch globally
mockFetch = jest.fn();
global.fetch = mockFetch;
apiService = new ApiService('https://api.example.com', 1000);
});
afterEach(() => {
// Restore original fetch
jest.restoreAllMocks();
});
describe('fetchUser', () => {
it('should fetch user successfully', async () => {
const mockUser = {
id: '123',
email: '[email protected]',
name: 'John Doe',
age: 30,
isActive: true,
roles: [],
createdAt: new Date().toISOString()
};
mockFetch.mockResolvedValue({
ok: true,
json: jest.fn().mockResolvedValue(mockUser)
});
const user = await apiService.fetchUser('123');
expect(user).toEqual(mockUser);
expect(mockFetch).toHaveBeenCalledWith(
'https://api.example.com/users/123',
{ signal: expect.any(AbortSignal) }
);
});
it('should throw error for empty user ID', async () => {
await expect(apiService.fetchUser('')).rejects.toThrow('User ID is required');
expect(mockFetch).not.toHaveBeenCalled();
});
it('should handle 404 error correctly', async () => {
mockFetch.mockResolvedValue({
ok: false,
status: 404,
statusText: 'Not Found'
});
await expect(apiService.fetchUser('999')).rejects.toThrow('User with ID 999 not found');
});
it('should handle other HTTP errors', async () => {
mockFetch.mockResolvedValue({
ok: false,
status: 500,
statusText: 'Internal Server Error'
});
await expect(apiService.fetchUser('123')).rejects.toThrow('HTTP 500: Internal Server Error');
});
it('should handle network errors', async () => {
mockFetch.mockRejectedValue(new Error('Network error'));
await expect(apiService.fetchUser('123')).rejects.toThrow('Network error');
});
it('should handle timeout', async () => {
// Mock a fetch that never resolves
mockFetch.mockImplementation(() => new Promise(() => {}));
// Create service with very short timeout
const shortTimeoutService = new ApiService('https://api.example.com', 100);
await expect(shortTimeoutService.fetchUser('123')).rejects.toThrow('Request timeout');
}, 10000);
it('should validate response data', async () => {
mockFetch.mockResolvedValue({
ok: true,
json: jest.fn().mockResolvedValue({
id: '123',
email: 'invalid-email', // Missing name and age
})
});
await expect(apiService.fetchUser('123')).rejects.toThrow('Invalid user name');
});
});
describe('createUser', () => {
it('should create user successfully', async () => {
const userData: CreateUserData = {
email: '[email protected]',
name: 'Jane Smith',
age: 28
};
const mockUser = {
id: '456',
...userData,
isActive: true,
roles: [],
createdAt: new Date().toISOString()
};
mockFetch.mockResolvedValue({
ok: true,
json: jest.fn().mockResolvedValue(mockUser)
});
const user = await apiService.createUser(userData);
expect(user).toEqual(mockUser);
expect(mockFetch).toHaveBeenCalledWith(
'https://api.example.com/users',
{
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify(userData)
}
);
});
it('should handle creation errors with error message', async () => {
const userData: CreateUserData = {
email: '[email protected]',
name: 'Existing User',
age: 25
};
mockFetch.mockResolvedValue({
ok: false,
status: 400,
statusText: 'Bad Request',
json: jest.fn().mockResolvedValue({
message: 'Email already exists'
})
});
await expect(apiService.createUser(userData)).rejects.toThrow('Email already exists');
});
it('should handle creation errors without error message', async () => {
const userData: CreateUserData = {
email: '[email protected]',
name: 'Test User',
age: 25
};
mockFetch.mockResolvedValue({
ok: false,
status: 500,
statusText: 'Internal Server Error',
json: jest.fn().mockRejectedValue(new Error('Invalid JSON'))
});
await expect(apiService.createUser(userData)).rejects.toThrow('HTTP 500: Internal Server Error');
});
});
describe('updateUser', () => {
it('should update user successfully', async () => {
const updates: UpdateUserData = {
name: 'Updated Name',
age: 35
};
const mockUser = {
id: '123',
email: '[email protected]',
name: 'Updated Name',
age: 35,
isActive: true,
roles: [],
createdAt: new Date().toISOString()
};
mockFetch.mockResolvedValue({
ok: true,
json: jest.fn().mockResolvedValue(mockUser)
});
const user = await apiService.updateUser('123', updates);
expect(user).toEqual(mockUser);
expect(mockFetch).toHaveBeenCalledWith(
'https://api.example.com/users/123',
{
method: 'PATCH',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify(updates)
}
);
});
it('should handle update errors', async () => {
mockFetch.mockResolvedValue({
ok: false,
status: 404,
statusText: 'Not Found'
});
await expect(apiService.updateUser('999', { name: 'New Name' }))
.rejects.toThrow('Failed to update user: Not Found');
});
});
describe('deleteUser', () => {
it('should delete user successfully', async () => {
mockFetch.mockResolvedValue({
ok: true,
status: 204
});
await expect(apiService.deleteUser('123')).resolves.toBeUndefined();
expect(mockFetch).toHaveBeenCalledWith(
'https://api.example.com/users/123',
{ method: 'DELETE' }
);
});
it('should handle 404 as success (idempotent)', async () => {
mockFetch.mockResolvedValue({
ok: false,
status: 404,
statusText: 'Not Found'
});
await expect(apiService.deleteUser('999')).resolves.toBeUndefined();
});
it('should handle other errors', async () => {
mockFetch.mockResolvedValue({
ok: false,
status: 500,
statusText: 'Internal Server Error'
});
await expect(apiService.deleteUser('123'))
.rejects.toThrow('Failed to delete user: Internal Server Error');
});
});
});
// 📊 Testing with test utilities and helpers
class TestUtilities {
static createMockUser(overrides: Partial<User> = {}): User {
return {
id: 'test-id',
email: '[email protected]',
name: 'Test User',
age: 25,
isActive: true,
roles: [],
createdAt: new Date(),
...overrides
};
}
static createMockUserData(overrides: Partial<CreateUserData> = {}): CreateUserData {
return {
email: '[email protected]',
name: 'Test User',
age: 25,
...overrides
};
}
static createMockRole(overrides: Partial<UserRole> = {}): UserRole {
return {
name: 'test-role',
permissions: ['read'],
...overrides
};
}
}
// Example of using test utilities
describe('UserManager with Test Utilities', () => {
let userManager: UserManager;
beforeEach(() => {
userManager = new UserManager();
});
it('should create users using test utilities', () => {
const userData = TestUtilities.createMockUserData({
email: '[email protected]',
roles: [
TestUtilities.createMockRole({
name: 'admin',
permissions: ['read', 'write', 'delete']
})
]
});
const user = userManager.createUser(userData);
expect(user.email).toBe('[email protected]');
expect(user.roles[0].name).toBe('admin');
expect(user.roles[0].permissions).toContain('delete');
});
it('should handle complex user scenarios', () => {
// Create multiple users with different roles
const adminUser = userManager.createUser(
TestUtilities.createMockUserData({
email: '[email protected]',
roles: [TestUtilities.createMockRole({ name: 'admin', permissions: ['read', 'write', 'delete'] })]
})
);
const editorUser = userManager.createUser(
TestUtilities.createMockUserData({
email: '[email protected]',
roles: [TestUtilities.createMockRole({ name: 'editor', permissions: ['read', 'write'] })]
})
);
const viewerUser = userManager.createUser(
TestUtilities.createMockUserData({
email: '[email protected]',
roles: [TestUtilities.createMockRole({ name: 'viewer', permissions: ['read'] })]
})
);
// Test role-based queries
const admins = userManager.getUsersByRole('admin');
const editors = userManager.getUsersByRole('editor');
const viewers = userManager.getUsersByRole('viewer');
expect(admins).toHaveLength(1);
expect(editors).toHaveLength(1);
expect(viewers).toHaveLength(1);
expect(admins[0].id).toBe(adminUser.id);
expect(editors[0].id).toBe(editorUser.id);
expect(viewers[0].id).toBe(viewerUser.id);
});
});
🎯 Conclusion
Congratulations! You’ve now mastered the art of writing comprehensive unit tests for TypeScript applications! 🎉
Throughout this tutorial, you’ve learned how to:
- Write effective unit tests that leverage TypeScript’s type system for better reliability and documentation
- Test complex scenarios including error conditions, edge cases, and async operations
- Use advanced testing patterns like proper mocking, test utilities, and helper functions
- Handle real-world complexity with comprehensive test suites for classes, functions, and API services
Unit testing in TypeScript is particularly powerful because you get the best of both worlds: compile-time type safety that prevents many errors, and runtime testing that verifies business logic and handles scenarios the type system can’t catch. Your tests become more maintainable, self-documenting, and reliable.
Remember: good unit tests are fast, isolated, repeatable, self-validating, and timely. They should test one thing at a time, be easy to understand, and provide clear feedback when something breaks. With TypeScript’s help, you can write tests that are not only functionally correct but also provide excellent development experience through IntelliSense and type checking.
Keep practicing these patterns, and you’ll find that well-tested TypeScript applications are incredibly robust, maintainable, and a joy to work with! 🚀