The Art of Unit Testing
Writing unit tests is like being a detective. You need to think of all possible scenarios where your code might behave unexpectedly, then create controlled experiments to verify it works correctly. Good unit tests are not just about finding bugs—they're about documenting behavior, enabling refactoring, and building confidence in your code.
What Makes a Good Unit Test?
Good unit tests share several characteristics. Think of them as the FIRST principles:
- Fast: Tests should run quickly (milliseconds, not seconds)
- Isolated: Tests should not depend on each other
- Repeatable: Same results every time they run
- Self-Validating: Tests should clearly pass or fail
- Timely: Written close to the production code
Example: Good vs Bad Unit Tests
// Bad: Slow, dependent on external service, not isolated
test('user service fetches data from API', async () => {
const userService = new UserService();
const user = await userService.getUser(1); // Makes real API call
expect(user.name).toBe('John Doe');
});
// Good: Fast, isolated, repeatable
test('user service returns user data', async () => {
const mockApi = {
fetchUser: jest.fn().mockResolvedValue({ id: 1, name: 'John Doe' })
};
const userService = new UserService(mockApi);
const user = await userService.getUser(1);
expect(user.name).toBe('John Doe');
expect(mockApi.fetchUser).toHaveBeenCalledWith(1);
});
Anatomy of a Unit Test
Every unit test should follow a clear structure that makes it easy to understand what's being tested and why:
describe('ShoppingCart', () => {
// Describe the unit being tested
describe('addItem', () => {
// Describe the specific method/behavior
it('should add an item to the cart', () => {
// Arrange: Set up the test data and conditions
const cart = new ShoppingCart();
const item = { id: 1, name: 'Book', price: 29.99 };
// Act: Execute the behavior being tested
cart.addItem(item);
// Assert: Verify the expected outcome
expect(cart.items).toHaveLength(1);
expect(cart.items[0]).toEqual(item);
});
it('should update quantity when adding existing item', () => {
// Each test should test ONE specific behavior
const cart = new ShoppingCart();
const item = { id: 1, name: 'Book', price: 29.99 };
cart.addItem(item, 1);
cart.addItem(item, 2);
expect(cart.items).toHaveLength(1);
expect(cart.items[0].quantity).toBe(3);
});
});
});
Testing Pure Functions
Pure functions are the easiest to test because they always produce the same output for the same input:
// Pure function to test
export function calculateDiscount(price, discountPercentage) {
if (price < 0 || discountPercentage < 0 || discountPercentage > 100) {
throw new Error('Invalid input');
}
const discount = price * (discountPercentage / 100);
return Number((price - discount).toFixed(2));
}
// Tests for pure function
describe('calculateDiscount', () => {
it('should calculate discount correctly', () => {
expect(calculateDiscount(100, 20)).toBe(80);
expect(calculateDiscount(50, 10)).toBe(45);
expect(calculateDiscount(75.50, 15)).toBe(64.18);
});
it('should handle zero discount', () => {
expect(calculateDiscount(100, 0)).toBe(100);
});
it('should handle 100% discount', () => {
expect(calculateDiscount(100, 100)).toBe(0);
});
it('should throw error for negative price', () => {
expect(() => calculateDiscount(-50, 10)).toThrow('Invalid input');
});
it('should throw error for invalid discount percentage', () => {
expect(() => calculateDiscount(100, -10)).toThrow('Invalid input');
expect(() => calculateDiscount(100, 150)).toThrow('Invalid input');
});
});
// Testing with multiple test cases
describe('calculateDiscount with test cases', () => {
const testCases = [
{ price: 100, discount: 20, expected: 80 },
{ price: 50, discount: 10, expected: 45 },
{ price: 75.50, discount: 15, expected: 64.18 },
{ price: 100, discount: 0, expected: 100 },
{ price: 100, discount: 100, expected: 0 },
];
testCases.forEach(({ price, discount, expected }) => {
it(`should return ${expected} for price ${price} with ${discount}% discount`, () => {
expect(calculateDiscount(price, discount)).toBe(expected);
});
});
});
Testing Classes and Objects
When testing classes, focus on the public interface and behavior, not implementation details:
// Class to test
export class BankAccount {
constructor(initialBalance = 0) {
this._balance = initialBalance;
this._transactions = [];
}
get balance() {
return this._balance;
}
deposit(amount) {
if (amount <= 0) {
throw new Error('Deposit amount must be positive');
}
this._balance += amount;
this._transactions.push({ type: 'deposit', amount, date: new Date() });
return this._balance;
}
withdraw(amount) {
if (amount <= 0) {
throw new Error('Withdrawal amount must be positive');
}
if (amount > this._balance) {
throw new Error('Insufficient funds');
}
this._balance -= amount;
this._transactions.push({ type: 'withdrawal', amount, date: new Date() });
return this._balance;
}
getTransactionHistory() {
return [...this._transactions];
}
}
// Tests for the class
describe('BankAccount', () => {
let account;
beforeEach(() => {
account = new BankAccount(100); // Start with $100
});
describe('constructor', () => {
it('should create account with initial balance', () => {
expect(account.balance).toBe(100);
});
it('should create account with zero balance by default', () => {
const emptyAccount = new BankAccount();
expect(emptyAccount.balance).toBe(0);
});
});
describe('deposit', () => {
it('should increase balance by deposit amount', () => {
account.deposit(50);
expect(account.balance).toBe(150);
});
it('should record deposit transaction', () => {
account.deposit(50);
const transactions = account.getTransactionHistory();
expect(transactions).toHaveLength(1);
expect(transactions[0]).toMatchObject({
type: 'deposit',
amount: 50,
date: expect.any(Date)
});
});
it('should throw error for non-positive amount', () => {
expect(() => account.deposit(0)).toThrow('Deposit amount must be positive');
expect(() => account.deposit(-50)).toThrow('Deposit amount must be positive');
});
});
describe('withdraw', () => {
it('should decrease balance by withdrawal amount', () => {
account.withdraw(30);
expect(account.balance).toBe(70);
});
it('should record withdrawal transaction', () => {
account.withdraw(30);
const transactions = account.getTransactionHistory();
expect(transactions).toHaveLength(1);
expect(transactions[0]).toMatchObject({
type: 'withdrawal',
amount: 30,
date: expect.any(Date)
});
});
it('should throw error for insufficient funds', () => {
expect(() => account.withdraw(150)).toThrow('Insufficient funds');
});
it('should throw error for non-positive amount', () => {
expect(() => account.withdraw(0)).toThrow('Withdrawal amount must be positive');
expect(() => account.withdraw(-50)).toThrow('Withdrawal amount must be positive');
});
});
describe('getTransactionHistory', () => {
it('should return copy of transactions', () => {
account.deposit(50);
const history = account.getTransactionHistory();
// Verify it's a copy, not the original array
history.push({ type: 'fake', amount: 1000 });
expect(account.getTransactionHistory()).toHaveLength(1);
});
});
});
Testing Asynchronous Code
Modern JavaScript is full of asynchronous operations. Here's how to test them effectively:
// Async functions to test
export class UserService {
constructor(apiClient) {
this.apiClient = apiClient;
this.cache = new Map();
}
async getUser(id) {
// Check cache first
if (this.cache.has(id)) {
return this.cache.get(id);
}
// Fetch from API
const user = await this.apiClient.fetchUser(id);
this.cache.set(id, user);
return user;
}
async createUser(userData) {
const user = await this.apiClient.createUser(userData);
this.cache.set(user.id, user);
return user;
}
async searchUsers(query) {
const users = await this.apiClient.searchUsers(query);
return users.map(user => ({
id: user.id,
displayName: `${user.firstName} ${user.lastName}`,
email: user.email
}));
}
}
// Tests for async code
describe('UserService', () => {
let userService;
let mockApiClient;
beforeEach(() => {
mockApiClient = {
fetchUser: jest.fn(),
createUser: jest.fn(),
searchUsers: jest.fn()
};
userService = new UserService(mockApiClient);
});
describe('getUser', () => {
it('should fetch user from API', async () => {
const mockUser = { id: 1, name: 'John Doe' };
mockApiClient.fetchUser.mockResolvedValue(mockUser);
const user = await userService.getUser(1);
expect(user).toEqual(mockUser);
expect(mockApiClient.fetchUser).toHaveBeenCalledWith(1);
});
it('should cache user after first fetch', async () => {
const mockUser = { id: 1, name: 'John Doe' };
mockApiClient.fetchUser.mockResolvedValue(mockUser);
// First call
await userService.getUser(1);
// Second call
const user = await userService.getUser(1);
expect(user).toEqual(mockUser);
expect(mockApiClient.fetchUser).toHaveBeenCalledTimes(1);
});
it('should handle API errors', async () => {
mockApiClient.fetchUser.mockRejectedValue(new Error('API Error'));
await expect(userService.getUser(1)).rejects.toThrow('API Error');
});
});
describe('createUser', () => {
it('should create user and add to cache', async () => {
const userData = { firstName: 'John', lastName: 'Doe' };
const createdUser = { id: 1, ...userData };
mockApiClient.createUser.mockResolvedValue(createdUser);
const user = await userService.createUser(userData);
expect(user).toEqual(createdUser);
expect(mockApiClient.createUser).toHaveBeenCalledWith(userData);
// Verify user is cached
mockApiClient.fetchUser.mockRejectedValue(new Error('Should not be called'));
const cachedUser = await userService.getUser(1);
expect(cachedUser).toEqual(createdUser);
});
});
describe('searchUsers', () => {
it('should transform search results', async () => {
const apiResults = [
{ id: 1, firstName: 'John', lastName: 'Doe', email: 'john@example.com' },
{ id: 2, firstName: 'Jane', lastName: 'Smith', email: 'jane@example.com' }
];
mockApiClient.searchUsers.mockResolvedValue(apiResults);
const results = await userService.searchUsers('john');
expect(results).toEqual([
{ id: 1, displayName: 'John Doe', email: 'john@example.com' },
{ id: 2, displayName: 'Jane Smith', email: 'jane@example.com' }
]);
});
});
});
Testing Error Scenarios
Don't just test the happy path—make sure your code handles errors gracefully:
// Error handling function to test
export class PaymentProcessor {
constructor(paymentGateway, logger) {
this.paymentGateway = paymentGateway;
this.logger = logger;
}
async processPayment(amount, cardDetails) {
try {
// Validate input
this.validateAmount(amount);
this.validateCardDetails(cardDetails);
// Process payment
const result = await this.paymentGateway.charge(amount, cardDetails);
// Log successful payment
this.logger.info(`Payment processed: ${result.transactionId}`);
return {
success: true,
transactionId: result.transactionId
};
} catch (error) {
// Log error
this.logger.error(`Payment failed: ${error.message}`);
// Categorize error
if (error.name === 'ValidationError') {
throw new Error(`Invalid input: ${error.message}`);
} else if (error.name === 'NetworkError') {
throw new Error('Payment gateway unavailable');
} else {
throw new Error('Payment processing failed');
}
}
}
validateAmount(amount) {
if (typeof amount !== 'number' || amount <= 0) {
const error = new Error('Amount must be a positive number');
error.name = 'ValidationError';
throw error;
}
}
validateCardDetails(cardDetails) {
if (!cardDetails || !cardDetails.number || !cardDetails.expiry || !cardDetails.cvv) {
const error = new Error('Invalid card details');
error.name = 'ValidationError';
throw error;
}
}
}
// Testing error scenarios
describe('PaymentProcessor', () => {
let processor;
let mockGateway;
let mockLogger;
beforeEach(() => {
mockGateway = {
charge: jest.fn()
};
mockLogger = {
info: jest.fn(),
error: jest.fn()
};
processor = new PaymentProcessor(mockGateway, mockLogger);
});
describe('processPayment - error handling', () => {
it('should handle invalid amount', async () => {
await expect(processor.processPayment(0, { number: '4111' }))
.rejects.toThrow('Invalid input: Amount must be a positive number');
expect(mockLogger.error).toHaveBeenCalled();
expect(mockGateway.charge).not.toHaveBeenCalled();
});
it('should handle invalid card details', async () => {
await expect(processor.processPayment(100, {}))
.rejects.toThrow('Invalid input: Invalid card details');
});
it('should handle payment gateway errors', async () => {
const networkError = new Error('Connection timeout');
networkError.name = 'NetworkError';
mockGateway.charge.mockRejectedValue(networkError);
await expect(processor.processPayment(100, {
number: '4111',
expiry: '12/25',
cvv: '123'
})).rejects.toThrow('Payment gateway unavailable');
expect(mockLogger.error).toHaveBeenCalledWith(
'Payment failed: Connection timeout'
);
});
it('should handle unexpected errors', async () => {
mockGateway.charge.mockRejectedValue(new Error('Unknown error'));
await expect(processor.processPayment(100, {
number: '4111',
expiry: '12/25',
cvv: '123'
})).rejects.toThrow('Payment processing failed');
});
});
describe('processPayment - success path', () => {
it('should process valid payment', async () => {
const mockResult = { transactionId: 'txn_123' };
mockGateway.charge.mockResolvedValue(mockResult);
const result = await processor.processPayment(100, {
number: '4111',
expiry: '12/25',
cvv: '123'
});
expect(result).toEqual({
success: true,
transactionId: 'txn_123'
});
expect(mockLogger.info).toHaveBeenCalledWith(
'Payment processed: txn_123'
);
});
});
});
Mocking Dependencies
Effective mocking is crucial for isolating units of code. Here are different mocking strategies:
// Dependencies to mock
import axios from 'axios';
import { v4 as uuidv4 } from 'uuid';
import { EventEmitter } from 'events';
export class NotificationService extends EventEmitter {
constructor(apiKey) {
super();
this.apiKey = apiKey;
this.queue = [];
this.processing = false;
}
async sendNotification(userId, message) {
const notification = {
id: uuidv4(),
userId,
message,
timestamp: new Date().toISOString(),
status: 'pending'
};
this.queue.push(notification);
this.emit('notificationQueued', notification);
if (!this.processing) {
this.processQueue();
}
return notification.id;
}
async processQueue() {
this.processing = true;
while (this.queue.length > 0) {
const notification = this.queue.shift();
try {
await this.sendToProvider(notification);
notification.status = 'sent';
this.emit('notificationSent', notification);
} catch (error) {
notification.status = 'failed';
notification.error = error.message;
this.emit('notificationFailed', notification);
}
}
this.processing = false;
}
async sendToProvider(notification) {
const response = await axios.post('https://api.notifications.com/send', {
key: this.apiKey,
to: notification.userId,
message: notification.message
});
return response.data;
}
}
// Comprehensive mocking examples
jest.mock('axios');
jest.mock('uuid');
describe('NotificationService', () => {
let service;
const mockApiKey = 'test-api-key';
beforeEach(() => {
// Reset all mocks
jest.clearAllMocks();
// Setup UUID mock
uuidv4.mockReturnValue('test-uuid-123');
// Setup axios mock
axios.post.mockResolvedValue({ data: { success: true } });
// Create fresh instance
service = new NotificationService(mockApiKey);
});
describe('sendNotification', () => {
it('should create notification with unique ID', async () => {
const id = await service.sendNotification('user1', 'Hello');
expect(id).toBe('test-uuid-123');
expect(uuidv4).toHaveBeenCalled();
});
it('should emit notificationQueued event', async () => {
const queuedHandler = jest.fn();
service.on('notificationQueued', queuedHandler);
await service.sendNotification('user1', 'Hello');
expect(queuedHandler).toHaveBeenCalledWith(
expect.objectContaining({
id: 'test-uuid-123',
userId: 'user1',
message: 'Hello',
status: 'pending'
})
);
});
});
describe('processQueue', () => {
it('should send notification to provider', async () => {
const sentHandler = jest.fn();
service.on('notificationSent', sentHandler);
await service.sendNotification('user1', 'Hello');
// Wait for processing to complete
await new Promise(resolve => setTimeout(resolve, 0));
expect(axios.post).toHaveBeenCalledWith(
'https://api.notifications.com/send',
{
key: mockApiKey,
to: 'user1',
message: 'Hello'
}
);
expect(sentHandler).toHaveBeenCalled();
});
it('should handle provider errors', async () => {
axios.post.mockRejectedValueOnce(new Error('API Error'));
const failedHandler = jest.fn();
service.on('notificationFailed', failedHandler);
await service.sendNotification('user1', 'Hello');
// Wait for processing to complete
await new Promise(resolve => setTimeout(resolve, 0));
expect(failedHandler).toHaveBeenCalledWith(
expect.objectContaining({
status: 'failed',
error: 'API Error'
})
);
});
});
// Testing with manual mocks
describe('with manual mocks', () => {
it('should work with custom mock implementation', async () => {
// Create a manual mock for axios
const mockAxios = {
post: jest.fn().mockImplementation((url, data) => {
if (data.to === 'invalid-user') {
return Promise.reject(new Error('Invalid user'));
}
return Promise.resolve({ data: { success: true, messageId: 'msg-123' } });
})
};
// Replace the module mock with our manual mock
jest.mock('axios', () => mockAxios);
// Test the implementation
const service = new NotificationService('api-key');
await expect(service.sendToProvider({ userId: 'invalid-user', message: 'Hi' }))
.rejects.toThrow('Invalid user');
});
});
});
Testing Edge Cases
Good tests explore the boundaries of your code's behavior:
// Function with edge cases
export class StringProcessor {
static truncate(str, maxLength, suffix = '...') {
if (typeof str !== 'string') {
throw new TypeError('Input must be a string');
}
if (typeof maxLength !== 'number' || maxLength < 0) {
throw new TypeError('Max length must be a non-negative number');
}
if (str.length <= maxLength) {
return str;
}
if (maxLength === 0) {
return '';
}
const truncatedLength = maxLength - suffix.length;
if (truncatedLength <= 0) {
return suffix.substring(0, maxLength);
}
return str.substring(0, truncatedLength) + suffix;
}
static wordWrap(text, maxLineLength) {
if (typeof text !== 'string') {
throw new TypeError('Text must be a string');
}
if (typeof maxLineLength !== 'number' || maxLineLength <= 0) {
throw new TypeError('Max line length must be a positive number');
}
const words = text.split(' ');
const lines = [];
let currentLine = '';
for (const word of words) {
if (word.length > maxLineLength) {
// Handle words longer than max length
if (currentLine) {
lines.push(currentLine);
currentLine = '';
}
// Break long word into chunks
for (let i = 0; i < word.length; i += maxLineLength) {
lines.push(word.substring(i, i + maxLineLength));
}
} else if ((currentLine + ' ' + word).trim().length <= maxLineLength) {
currentLine = (currentLine + ' ' + word).trim();
} else {
if (currentLine) {
lines.push(currentLine);
}
currentLine = word;
}
}
if (currentLine) {
lines.push(currentLine);
}
return lines.join('\n');
}
}
// Testing edge cases
describe('StringProcessor', () => {
describe('truncate', () => {
// Normal cases
it('should truncate long strings', () => {
expect(StringProcessor.truncate('Hello World', 8)).toBe('Hello...');
});
it('should not truncate short strings', () => {
expect(StringProcessor.truncate('Hello', 10)).toBe('Hello');
});
// Edge cases
it('should handle empty strings', () => {
expect(StringProcessor.truncate('', 5)).toBe('');
});
it('should handle zero max length', () => {
expect(StringProcessor.truncate('Hello', 0)).toBe('');
});
it('should handle max length equal to string length', () => {
expect(StringProcessor.truncate('Hello', 5)).toBe('Hello');
});
it('should handle max length less than suffix length', () => {
expect(StringProcessor.truncate('Hello World', 2, '...')).toBe('..');
});
it('should handle custom suffix', () => {
expect(StringProcessor.truncate('Hello World', 8, '…')).toBe('Hello W…');
});
// Error cases
it('should throw TypeError for non-string input', () => {
expect(() => StringProcessor.truncate(123, 5)).toThrow(TypeError);
expect(() => StringProcessor.truncate(null, 5)).toThrow(TypeError);
});
it('should throw TypeError for invalid max length', () => {
expect(() => StringProcessor.truncate('Hello', -1)).toThrow(TypeError);
expect(() => StringProcessor.truncate('Hello', 'five')).toThrow(TypeError);
});
});
describe('wordWrap', () => {
// Normal cases
it('should wrap text at word boundaries', () => {
const text = 'The quick brown fox jumps over the lazy dog';
const wrapped = StringProcessor.wordWrap(text, 15);
expect(wrapped).toBe(
'The quick brown\n' +
'fox jumps over\n' +
'the lazy dog'
);
});
// Edge cases
it('should handle empty string', () => {
expect(StringProcessor.wordWrap('', 10)).toBe('');
});
it('should handle single word longer than max length', () => {
expect(StringProcessor.wordWrap('supercalifragilisticexpialidocious', 10)).toBe(
'supercalif\n' +
'ragilistic\n' +
'expialidoc\n' +
'ious'
);
});
it('should handle text with multiple spaces', () => {
expect(StringProcessor.wordWrap('Hello World', 10)).toBe('Hello\nWorld');
});
it('should handle text that exactly fits', () => {
expect(StringProcessor.wordWrap('Hello', 5)).toBe('Hello');
});
// Error cases
it('should throw TypeError for non-string text', () => {
expect(() => StringProcessor.wordWrap(123, 10)).toThrow(TypeError);
});
it('should throw TypeError for invalid max line length', () => {
expect(() => StringProcessor.wordWrap('Hello', 0)).toThrow(TypeError);
expect(() => StringProcessor.wordWrap('Hello', -5)).toThrow(TypeError);
});
});
});
Test Organization Patterns
Organize your tests for maintainability and clarity:
// Well-organized test structure
describe('UserRepository', () => {
let repository;
let mockDatabase;
// Setup and teardown
beforeAll(async () => {
// One-time setup
});
afterAll(async () => {
// One-time cleanup
});
beforeEach(() => {
// Reset state before each test
mockDatabase = createMockDatabase();
repository = new UserRepository(mockDatabase);
});
afterEach(() => {
// Clean up after each test
jest.clearAllMocks();
});
// Group related tests
describe('findById', () => {
describe('when user exists', () => {
it('should return user data', async () => {
// Test implementation
});
it('should cache the result', async () => {
// Test implementation
});
});
describe('when user does not exist', () => {
it('should return null', async () => {
// Test implementation
});
});
describe('when database error occurs', () => {
it('should throw repository error', async () => {
// Test implementation
});
});
});
describe('create', () => {
describe('with valid data', () => {
it('should create user in database', async () => {
// Test implementation
});
it('should return created user with ID', async () => {
// Test implementation
});
});
describe('with invalid data', () => {
it('should throw validation error', async () => {
// Test implementation
});
});
describe('when email already exists', () => {
it('should throw duplicate error', async () => {
// Test implementation
});
});
});
// Test utilities
function createMockDatabase() {
return {
query: jest.fn(),
insert: jest.fn(),
update: jest.fn(),
delete: jest.fn()
};
}
function createTestUser(overrides = {}) {
return {
email: 'test@example.com',
password: 'password123',
firstName: 'Test',
lastName: 'User',
...overrides
};
}
});
Advanced Testing Patterns
Here are some advanced patterns for complex testing scenarios:
1. Testing with Dependency Injection
// Service with dependency injection
export class OrderService {
constructor(
private paymentProcessor: PaymentProcessor,
private inventoryService: InventoryService,
private notificationService: NotificationService,
private logger: Logger
) {}
async placeOrder(order: Order): Promise {
try {
// Check inventory
const available = await this.inventoryService.checkAvailability(order.items);
if (!available) {
throw new Error('Items not available');
}
// Process payment
const paymentResult = await this.paymentProcessor.processPayment(
order.total,
order.paymentDetails
);
// Update inventory
await this.inventoryService.reserveItems(order.items);
// Send notification
await this.notificationService.sendOrderConfirmation(order);
this.logger.info(`Order placed: ${order.id}`);
return {
orderId: order.id,
status: 'completed',
transactionId: paymentResult.transactionId
};
} catch (error) {
this.logger.error(`Order failed: ${error.message}`);
throw error;
}
}
}
// Test with dependency injection
describe('OrderService', () => {
let orderService: OrderService;
let mockPaymentProcessor: jest.Mocked;
let mockInventoryService: jest.Mocked;
let mockNotificationService: jest.Mocked;
let mockLogger: jest.Mocked;
beforeEach(() => {
// Create mocks
mockPaymentProcessor = {
processPayment: jest.fn()
} as any;
mockInventoryService = {
checkAvailability: jest.fn(),
reserveItems: jest.fn()
} as any;
mockNotificationService = {
sendOrderConfirmation: jest.fn()
} as any;
mockLogger = {
info: jest.fn(),
error: jest.fn()
} as any;
// Inject mocks
orderService = new OrderService(
mockPaymentProcessor,
mockInventoryService,
mockNotificationService,
mockLogger
);
});
it('should place order successfully', async () => {
// Arrange
const order = createTestOrder();
mockInventoryService.checkAvailability.mockResolvedValue(true);
mockPaymentProcessor.processPayment.mockResolvedValue({
transactionId: 'txn_123'
});
// Act
const result = await orderService.placeOrder(order);
// Assert
expect(result).toEqual({
orderId: order.id,
status: 'completed',
transactionId: 'txn_123'
});
expect(mockInventoryService.checkAvailability).toHaveBeenCalledWith(order.items);
expect(mockPaymentProcessor.processPayment).toHaveBeenCalledWith(
order.total,
order.paymentDetails
);
expect(mockInventoryService.reserveItems).toHaveBeenCalledWith(order.items);
expect(mockNotificationService.sendOrderConfirmation).toHaveBeenCalledWith(order);
expect(mockLogger.info).toHaveBeenCalledWith(`Order placed: ${order.id}`);
});
});
2. Testing with Test Doubles
// Different types of test doubles
describe('Test Doubles Examples', () => {
// Dummy - passed but not used
test('dummy example', () => {
const dummyLogger = {};
const service = new ServiceNeedingLogger(dummyLogger);
expect(service.doSomethingNotUsingLogger()).toBe(true);
});
// Stub - provides canned answers
test('stub example', () => {
const stubDatabase = {
getUser: () => ({ id: 1, name: 'John' })
};
const service = new UserService(stubDatabase);
expect(service.getUserName(1)).toBe('John');
});
// Spy - records information about calls
test('spy example', () => {
const spyEmail = {
send: jest.fn()
};
const service = new NotificationService(spyEmail);
service.notifyUser('user@example.com', 'Hello');
expect(spyEmail.send).toHaveBeenCalledWith('user@example.com', 'Hello');
});
// Mock - pre-programmed with expectations
test('mock example', () => {
const mockPayment = {
charge: jest.fn().mockResolvedValue({ success: true })
};
const service = new PaymentService(mockPayment);
return service.processPayment(100).then(result => {
expect(result.success).toBe(true);
expect(mockPayment.charge).toHaveBeenCalledWith(100);
});
});
// Fake - working implementation with shortcuts
test('fake example', () => {
class FakeDatabase {
private data = new Map();
async save(id, value) {
this.data.set(id, value);
}
async find(id) {
return this.data.get(id);
}
}
const fakeDb = new FakeDatabase();
const service = new RepositoryService(fakeDb);
return service.saveUser({ id: 1, name: 'John' })
.then(() => service.getUser(1))
.then(user => expect(user.name).toBe('John'));
});
});
Testing Best Practices
Follow these best practices for maintainable, reliable tests:
1. Keep Tests Independent
// Bad: Tests depend on execution order
let sharedCounter = 0;
test('first test', () => {
sharedCounter++;
expect(sharedCounter).toBe(1);
});
test('second test', () => {
sharedCounter++;
expect(sharedCounter).toBe(2); // Fails if tests run in different order
});
// Good: Each test is independent
test('first test', () => {
const counter = 0;
expect(counter + 1).toBe(1);
});
test('second test', () => {
const counter = 0;
expect(counter + 2).toBe(2);
});
2. Use Descriptive Test Names
// Bad: Unclear test names
test('test1', () => { /* ... */ });
test('error case', () => { /* ... */ });
// Good: Descriptive test names
test('should return null when user ID does not exist', () => { /* ... */ });
test('should throw ValidationError when email format is invalid', () => { /* ... */ });
3. Follow AAA Pattern
test('should calculate order total with tax and shipping', () => {
// Arrange
const order = {
items: [
{ price: 10, quantity: 2 },
{ price: 5, quantity: 1 }
],
shippingMethod: 'express'
};
// Act
const total = calculateOrderTotal(order);
// Assert
expect(total).toBe(25.75); // (20 + 5) * 1.1 (tax) + 3 (shipping)
});
4. Test One Thing Per Test
// Bad: Testing multiple behaviors
test('user validation', () => {
expect(validateUser({ name: '' })).toBe(false);
expect(validateUser({ name: 'John', age: -5 })).toBe(false);
expect(validateUser({ name: 'John', age: 25 })).toBe(true);
});
// Good: Separate tests for each behavior
describe('validateUser', () => {
test('should return false when name is empty', () => {
expect(validateUser({ name: '' })).toBe(false);
});
test('should return false when age is negative', () => {
expect(validateUser({ name: 'John', age: -5 })).toBe(false);
});
test('should return true when all fields are valid', () => {
expect(validateUser({ name: 'John', age: 25 })).toBe(true);
});
});
5. Avoid Testing Implementation Details
// Bad: Testing implementation details
class UserService {
private cache = new Map();
getUser(id) {
if (this.cache.has(id)) {
return this.cache.get(id);
}
// ... fetch user
}
}
test('should use cache', () => {
const service = new UserService();
// Don't test private implementation details
expect(service.cache.has(1)).toBe(false); // Bad!
});
// Good: Test behavior, not implementation
test('should return cached user on second call', () => {
const service = new UserService();
const mockFetch = jest.spyOn(service, 'fetchUser');
service.getUser(1);
service.getUser(1);
expect(mockFetch).toHaveBeenCalledTimes(1); // Good!
});
Common Anti-Patterns to Avoid
1. Testing Too Much in One Test
// Anti-pattern: Kitchen sink test
test('shopping cart functionality', () => {
const cart = new ShoppingCart();
// Testing everything in one test
cart.addItem({ id: 1, price: 10 });
expect(cart.itemCount).toBe(1);
cart.addItem({ id: 2, price: 20 });
expect(cart.itemCount).toBe(2);
expect(cart.total).toBe(30);
cart.removeItem(1);
expect(cart.itemCount).toBe(1);
cart.clear();
expect(cart.itemCount).toBe(0);
});
// Better: Focused tests
describe('ShoppingCart', () => {
let cart;
beforeEach(() => {
cart = new ShoppingCart();
});
test('should add item to cart', () => {
cart.addItem({ id: 1, price: 10 });
expect(cart.itemCount).toBe(1);
});
test('should calculate total', () => {
cart.addItem({ id: 1, price: 10 });
cart.addItem({ id: 2, price: 20 });
expect(cart.total).toBe(30);
});
test('should remove item from cart', () => {
cart.addItem({ id: 1, price: 10 });
cart.removeItem(1);
expect(cart.itemCount).toBe(0);
});
});
2. Excessive Mocking
// Anti-pattern: Over-mocking
test('overly mocked test', () => {
const mockMath = {
add: jest.fn().mockReturnValue(5),
subtract: jest.fn().mockReturnValue(3)
};
const calculator = new Calculator(mockMath);
expect(calculator.calculate('2 + 3')).toBe(5);
// Not actually testing the calculation logic!
});
// Better: Mock only external dependencies
test('calculator with real math', () => {
const calculator = new Calculator();
expect(calculator.calculate('2 + 3')).toBe(5);
// Actually tests the calculation logic
});
3. Testing Framework Code
// Anti-pattern: Testing the framework
test('React renders component', () => {
const component = render();
expect(component).toBeTruthy();
// Just testing that React works!
});
// Better: Test your component's behavior
test('button calls onClick when clicked', () => {
const handleClick = jest.fn();
render();
fireEvent.click(screen.getByText('Click me'));
expect(handleClick).toHaveBeenCalledTimes(1);
});
Practical Exercise: Testing a Shopping Cart
Let's apply what we've learned by testing a shopping cart system:
// shoppingCart.js
export class ShoppingCart {
constructor(pricingService, inventoryService) {
this.items = [];
this.pricingService = pricingService;
this.inventoryService = inventoryService;
}
async addItem(productId, quantity = 1) {
// Check inventory
const available = await this.inventoryService.checkStock(productId);
if (available < quantity) {
throw new Error('Insufficient stock');
}
// Get product details
const product = await this.inventoryService.getProduct(productId);
// Check if item already in cart
const existingItem = this.items.find(item => item.productId === productId);
if (existingItem) {
existingItem.quantity += quantity;
} else {
this.items.push({
productId,
name: product.name,
price: product.price,
quantity
});
}
return this.getItemCount();
}
removeItem(productId) {
const index = this.items.findIndex(item => item.productId === productId);
if (index !== -1) {
this.items.splice(index, 1);
}
}
updateQuantity(productId, quantity) {
if (quantity < 0) {
throw new Error('Quantity cannot be negative');
}
const item = this.items.find(item => item.productId === productId);
if (!item) {
throw new Error('Item not found in cart');
}
if (quantity === 0) {
this.removeItem(productId);
} else {
item.quantity = quantity;
}
}
async getTotal() {
const subtotal = this.items.reduce((total, item) => {
return total + (item.price * item.quantity);
}, 0);
const tax = await this.pricingService.calculateTax(subtotal);
const shipping = await this.pricingService.calculateShipping(this.items);
return {
subtotal,
tax,
shipping,
total: subtotal + tax + shipping
};
}
getItemCount() {
return this.items.reduce((count, item) => count + item.quantity, 0);
}
clear() {
this.items = [];
}
}
// shoppingCart.test.js
describe('ShoppingCart', () => {
let cart;
let mockPricingService;
let mockInventoryService;
beforeEach(() => {
mockPricingService = {
calculateTax: jest.fn(),
calculateShipping: jest.fn()
};
mockInventoryService = {
checkStock: jest.fn(),
getProduct: jest.fn()
};
cart = new ShoppingCart(mockPricingService, mockInventoryService);
});
describe('addItem', () => {
const mockProduct = {
id: 'prod1',
name: 'Test Product',
price: 10.99
};
beforeEach(() => {
mockInventoryService.checkStock.mockResolvedValue(10);
mockInventoryService.getProduct.mockResolvedValue(mockProduct);
});
it('should add new item to cart', async () => {
const itemCount = await cart.addItem('prod1', 2);
expect(itemCount).toBe(2);
expect(cart.items).toHaveLength(1);
expect(cart.items[0]).toEqual({
productId: 'prod1',
name: 'Test Product',
price: 10.99,
quantity: 2
});
});
it('should update quantity for existing item', async () => {
await cart.addItem('prod1', 2);
await cart.addItem('prod1', 3);
expect(cart.items).toHaveLength(1);
expect(cart.items[0].quantity).toBe(5);
});
it('should throw error for insufficient stock', async () => {
mockInventoryService.checkStock.mockResolvedValue(1);
await expect(cart.addItem('prod1', 2))
.rejects.toThrow('Insufficient stock');
});
});
describe('removeItem', () => {
beforeEach(async () => {
mockInventoryService.checkStock.mockResolvedValue(10);
mockInventoryService.getProduct.mockResolvedValue({
id: 'prod1',
name: 'Test Product',
price: 10.99
});
await cart.addItem('prod1', 2);
});
it('should remove item from cart', () => {
cart.removeItem('prod1');
expect(cart.items).toHaveLength(0);
});
it('should do nothing if item not found', () => {
cart.removeItem('nonexistent');
expect(cart.items).toHaveLength(1);
});
});
describe('updateQuantity', () => {
beforeEach(async () => {
mockInventoryService.checkStock.mockResolvedValue(10);
mockInventoryService.getProduct.mockResolvedValue({
id: 'prod1',
name: 'Test Product',
price: 10.99
});
await cart.addItem('prod1', 2);
});
it('should update item quantity', () => {
cart.updateQuantity('prod1', 5);
expect(cart.items[0].quantity).toBe(5);
});
it('should remove item when quantity is 0', () => {
cart.updateQuantity('prod1', 0);
expect(cart.items).toHaveLength(0);
});
it('should throw error for negative quantity', () => {
expect(() => cart.updateQuantity('prod1', -1))
.toThrow('Quantity cannot be negative');
});
it('should throw error for non-existent item', () => {
expect(() => cart.updateQuantity('nonexistent', 5))
.toThrow('Item not found in cart');
});
});
describe('getTotal', () => {
beforeEach(async () => {
mockInventoryService.checkStock.mockResolvedValue(10);
mockInventoryService.getProduct
.mockResolvedValueOnce({ id: 'prod1', name: 'Product 1', price: 10 })
.mockResolvedValueOnce({ id: 'prod2', name: 'Product 2', price: 20 });
await cart.addItem('prod1', 2);
await cart.addItem('prod2', 1);
mockPricingService.calculateTax.mockResolvedValue(4);
mockPricingService.calculateShipping.mockResolvedValue(5);
});
it('should calculate correct totals', async () => {
const totals = await cart.getTotal();
expect(totals).toEqual({
subtotal: 40, // (10 * 2) + (20 * 1)
tax: 4,
shipping: 5,
total: 49
});
expect(mockPricingService.calculateTax).toHaveBeenCalledWith(40);
expect(mockPricingService.calculateShipping).toHaveBeenCalledWith(cart.items);
});
});
});
Summary and Best Practices Checklist
Use this checklist when writing unit tests:
✓ Test Structure
- □ Use descriptive test names that explain the expected behavior
- □ Follow the AAA (Arrange-Act-Assert) pattern
- □ Group related tests using describe blocks
- □ Test one behavior per test
✓ Test Quality
- □ Tests are deterministic (same result every time)
- □ Tests are independent (can run in any order)
- □ Tests are fast (milliseconds, not seconds)
- □ Tests cover both happy path and error cases
- □ Tests check edge cases and boundary conditions
✓ Mocking and Dependencies
- □ Mock external dependencies (APIs, databases, file system)
- □ Use dependency injection for better testability
- □ Don't mock what you don't own
- □ Keep mocks simple and focused
✓ Maintainability
- □ Tests are readable and self-documenting
- □ Common setup is extracted to beforeEach/afterEach
- □ Test utilities and factories are used for complex setup
- □ Tests don't test implementation details
✓ Coverage
- □ Critical paths have test coverage
- □ Error conditions are tested
- □ Edge cases are covered
- □ Integration points are tested