What Makes a Test Effective?
Effective tests are like good navigation systems - they guide you to your destination without getting in your way or sending you down dead ends. They have several key characteristics:
- Trustworthy: Tests should accurately verify correctness without false positives/negatives
- Maintainable: Tests should be easy to update when requirements change
- Readable: The intent of tests should be clear to other developers
- Fast: Tests should run quickly to support rapid development
- Focused: Each test should verify a specific behavior or condition
The Cost of Poor Tests
A well-known online retailer discovered that 30% of their development time was spent maintaining brittle tests that were constantly breaking due to minor changes in implementation details. After refactoring their tests to focus on behavior rather than implementation, this dropped to just 5%.
Test Structure and Organization
The Four-Phase Test Pattern
A well-structured test has four distinct phases, sometimes called the "Four A's":
- Arrange: Set up the test conditions and inputs
- Act: Execute the code being tested
- Assert: Verify the results match expectations
- Aftermath: Clean up resources (if necessary)
test('cart calculates correct total with discount', () => {
// Arrange
const cart = new ShoppingCart();
cart.addItem({ id: 1, name: 'Widget', price: 10.00 }, 2);
cart.addItem({ id: 2, name: 'Gadget', price: 5.00 }, 1);
const discount = { code: 'SAVE20', percentage: 20 };
// Act
cart.applyDiscount(discount);
const total = cart.calculateTotal();
// Assert
expect(total).toBe(20.00);
// Aftermath (if needed)
// cart.reset();
});
Logical Grouping with describe()
Group related tests to create a clear hierarchy of test cases:
describe('UserService', () => {
describe('registration', () => {
test('creates new user with valid data', () => { /* ... */ });
test('rejects duplicate usernames', () => { /* ... */ });
test('requires password with minimum length', () => { /* ... */ });
});
describe('authentication', () => {
test('generates token for valid credentials', () => { /* ... */ });
test('rejects invalid passwords', () => { /* ... */ });
test('locks account after failed attempts', () => { /* ... */ });
});
});
Using Setup and Teardown Effectively
Extract common setup and cleanup code to keep tests DRY (Don't Repeat Yourself):
describe('DatabaseService', () => {
let dbService;
let testConnection;
// Global setup for all tests in this group
beforeAll(async () => {
testConnection = await createTestDatabase();
});
// Setup for each individual test
beforeEach(() => {
dbService = new DatabaseService(testConnection);
return dbService.clearAllTables();
});
// Cleanup after each test
afterEach(() => {
return dbService.clearAllTables();
});
// Global teardown
afterAll(async () => {
await testConnection.close();
});
test('stores user in database', async () => {
const user = { name: 'Alice', email: 'alice@example.com' };
await dbService.saveUser(user);
const savedUser = await dbService.findUserByEmail('alice@example.com');
expect(savedUser.name).toBe('Alice');
});
// More tests...
});
Scope of Setup and Teardown
Be mindful of what you put in setup and teardown blocks:
- beforeAll/afterAll: Expensive operations that can be shared (opening database connections, starting servers)
- beforeEach/afterEach: Creating fresh test data, resetting state between tests
Only move code to setup when it's truly shared across multiple tests. Test-specific setup should remain in the test body for clarity.
Testing Patterns and Techniques
Testing Pure Functions
Pure functions (those that always return the same output for the same input without side effects) are the easiest to test:
// The function to test
function calculateDiscount(price, discountPercentage) {
if (price <= 0 || discountPercentage < 0 || discountPercentage > 100) {
throw new Error('Invalid input');
}
return price * (1 - discountPercentage / 100);
}
// Tests
describe('calculateDiscount', () => {
test('applies discount correctly', () => {
expect(calculateDiscount(100, 20)).toBe(80);
expect(calculateDiscount(50, 10)).toBe(45);
expect(calculateDiscount(200, 0)).toBe(200);
});
test('handles floating point values', () => {
expect(calculateDiscount(99.99, 15)).toBeCloseTo(84.99);
});
test('throws error on invalid inputs', () => {
expect(() => calculateDiscount(-50, 10)).toThrow('Invalid input');
expect(() => calculateDiscount(50, -10)).toThrow('Invalid input');
expect(() => calculateDiscount(50, 110)).toThrow('Invalid input');
});
});
Parameterized Tests
When you need to test a function with multiple inputs and expected outputs, parameterized tests reduce repetition:
describe('validateEmail', () => {
// Using test.each for parameterized tests
test.each([
// [input, expected result]
['valid@example.com', true],
['firstname.lastname@domain.com', true],
['email@subdomain.domain.com', true],
['', false],
['invalid.email', false],
['@missing-username.com', false],
['spaces in@email.com', false],
['missing@domain', false]
])('validateEmail(%s) should return %s', (email, expected) => {
expect(validateEmail(email)).toBe(expected);
});
});
Testing State Changes
For functions that change state, test both the state change and the return value:
class Counter {
constructor() {
this.count = 0;
}
increment() {
this.count += 1;
return this.count;
}
decrement() {
this.count -= 1;
return this.count;
}
reset() {
this.count = 0;
return this.count;
}
}
describe('Counter', () => {
let counter;
beforeEach(() => {
counter = new Counter();
});
test('should start at zero', () => {
expect(counter.count).toBe(0);
});
test('increment should add one and return new value', () => {
const returnValue = counter.increment();
expect(counter.count).toBe(1); // Test state change
expect(returnValue).toBe(1); // Test return value
});
test('decrement should subtract one and return new value', () => {
counter.increment(); // Setup: count = 1
const returnValue = counter.decrement();
expect(counter.count).toBe(0); // Test state change
expect(returnValue).toBe(0); // Test return value
});
test('reset should set count to zero and return zero', () => {
counter.increment();
counter.increment(); // Setup: count = 2
const returnValue = counter.reset();
expect(counter.count).toBe(0); // Test state change
expect(returnValue).toBe(0); // Test return value
});
});
Testing Code That Makes API Calls
Use mocks to isolate your code from external dependencies:
// userService.js
class UserService {
constructor(apiClient) {
this.apiClient = apiClient;
}
async getUserProfile(userId) {
try {
const response = await this.apiClient.get(`/users/${userId}`);
return {
id: response.data.id,
name: response.data.name,
email: response.data.email,
isAdmin: response.data.role === 'admin'
};
} catch (error) {
if (error.response && error.response.status === 404) {
return null;
}
throw new Error(`Failed to get user: ${error.message}`);
}
}
}
// userService.test.js
describe('UserService', () => {
let userService;
let mockApiClient;
beforeEach(() => {
mockApiClient = {
get: jest.fn()
};
userService = new UserService(mockApiClient);
});
describe('getUserProfile', () => {
test('transforms user data correctly', async () => {
// Mock the API response
mockApiClient.get.mockResolvedValue({
data: {
id: '123',
name: 'John Doe',
email: 'john@example.com',
role: 'admin',
created_at: '2023-01-01T00:00:00Z',
updated_at: '2023-01-02T00:00:00Z',
// Additional fields not needed by our app
address: '123 Main St',
phone: '555-1234'
}
});
const profile = await userService.getUserProfile('123');
expect(mockApiClient.get).toHaveBeenCalledWith('/users/123');
expect(profile).toEqual({
id: '123',
name: 'John Doe',
email: 'john@example.com',
isAdmin: true
});
});
test('returns null for non-existent users', async () => {
// Mock a 404 response
const error = new Error('Not found');
error.response = { status: 404 };
mockApiClient.get.mockRejectedValue(error);
const profile = await userService.getUserProfile('999');
expect(profile).toBeNull();
});
test('rethrows other errors', async () => {
// Mock a network error
mockApiClient.get.mockRejectedValue(new Error('Network failure'));
await expect(userService.getUserProfile('123'))
.rejects.toThrow('Failed to get user: Network failure');
});
});
});
Testing Edge Cases and Boundaries
Testing isn't just about the happy path - thorough testing includes edge cases and boundary conditions. Think of it like crash-testing a car at different angles, not just head-on.
Common Edge Cases to Test
- Empty inputs: Empty strings, arrays, objects
- Null/undefined values: When parameters might be absent
- Extreme values: Very large numbers, empty collections
- Boundary values: Values at the edge of valid ranges
- Type mismatches: Passing strings where numbers are expected
- Malformed inputs: Invalid formats, corrupted data
- Error conditions: Network failures, timeouts
- Concurrent operations: Race conditions
Example: Testing a Payment Processing Function
// The function to test
function processPayment(amount, cardDetails) {
if (!amount || typeof amount !== 'number' || amount <= 0) {
throw new Error('Invalid payment amount');
}
if (!cardDetails || !cardDetails.number || !cardDetails.expiryDate || !cardDetails.cvv) {
throw new Error('Missing card details');
}
if (!/^\d{16}$/.test(cardDetails.number)) {
throw new Error('Invalid card number');
}
// Simplified payment processing logic
return {
success: true,
transactionId: 'txn_' + Math.random().toString(36).substr(2, 9),
amount: amount,
fee: amount * 0.029 + 0.30, // 2.9% + 30ยข fee
netAmount: amount - (amount * 0.029 + 0.30)
};
}
// Tests
describe('processPayment', () => {
const validCard = {
number: '4111111111111111',
expiryDate: '12/25',
cvv: '123'
};
test('processes valid payment', () => {
const result = processPayment(100, validCard);
expect(result.success).toBe(true);
expect(result.transactionId).toMatch(/^txn_/);
expect(result.amount).toBe(100);
expect(result.fee).toBeCloseTo(3.20);
expect(result.netAmount).toBeCloseTo(96.80);
});
describe('edge cases', () => {
test('rejects zero amount', () => {
expect(() => processPayment(0, validCard)).toThrow('Invalid payment amount');
});
test('rejects negative amount', () => {
expect(() => processPayment(-50, validCard)).toThrow('Invalid payment amount');
});
test('rejects non-number amount', () => {
expect(() => processPayment('100', validCard)).toThrow('Invalid payment amount');
});
test('rejects undefined amount', () => {
expect(() => processPayment(undefined, validCard)).toThrow('Invalid payment amount');
});
test('handles very small payment amounts', () => {
const result = processPayment(0.01, validCard);
expect(result.success).toBe(true);
expect(result.netAmount).toBeCloseTo(-0.29);
});
test('handles very large payment amounts', () => {
const result = processPayment(1000000, validCard);
expect(result.success).toBe(true);
expect(result.fee).toBeCloseTo(29000.30);
expect(result.netAmount).toBeCloseTo(970999.70);
});
test('rejects missing card details', () => {
expect(() => processPayment(100)).toThrow('Missing card details');
expect(() => processPayment(100, {})).toThrow('Missing card details');
expect(() => processPayment(100, { number: '4111111111111111' })).toThrow('Missing card details');
});
test('validates card number format', () => {
expect(() => processPayment(100, { ...validCard, number: '411' })).toThrow('Invalid card number');
expect(() => processPayment(100, { ...validCard, number: 'abcdefghijklmnop' })).toThrow('Invalid card number');
});
});
});
CORRECT Boundary Testing
The CORRECT acronym helps remember important boundary conditions:
- Conformance - Does the value conform to an expected format?
- Ordering - Is the set of values ordered or unordered as appropriate?
- Range - Is the value within acceptable range boundaries?
- Reference - Does the code reference anything external that isn't controlled by the code itself?
- Existence - Does the value exist (e.g., non-null, non-zero, present in a set)?
- Cardinality - Are there exactly enough values?
- Time - Is everything happening in the right order? At the right time?
Test Doubles: Mocks, Stubs, Spies, and Fakes
Test doubles replace real objects in tests to isolate the code being tested. Each type serves a different purpose:
Dummy
Objects that are passed around but never actually used. They're just used to fill parameter lists.
// Example: A dummy logger that does nothing
const dummyLogger = {
log: () => {}
};
test('processOrder works without logging', () => {
const result = orderProcessor.processOrder(order, dummyLogger);
expect(result.success).toBe(true);
});
Stub
Provides predefined answers to calls made during the test, usually not responding to anything outside what's programmed for the test.
// A stub that simulates a payment gateway
const paymentGatewayStub = {
processPayment: () => ({
success: true,
transactionId: 'test-transaction-123'
})
};
test('completeOrder succeeds when payment is successful', async () => {
const order = new Order({ items: [{id: 1, quantity: 1}], total: 100 });
const result = await orderService.completeOrder(order, paymentGatewayStub);
expect(result.status).toBe('completed');
expect(result.paymentId).toBe('test-transaction-123');
});
Spy
Records calls to functions but lets the original implementation run. Used to verify function calls without changing behavior.
test('confirmOrder calls email service with correct data', async () => {
// Create a spy on the sendEmail method
const emailServiceSpy = jest.spyOn(emailService, 'sendEmail');
// Run the code that should call sendEmail
await orderService.confirmOrder('order-123');
// Verify sendEmail was called correctly
expect(emailServiceSpy).toHaveBeenCalled();
expect(emailServiceSpy).toHaveBeenCalledWith(
expect.objectContaining({
subject: 'Order Confirmation',
orderId: 'order-123'
})
);
// Restore the original implementation
emailServiceSpy.mockRestore();
});
Mock
Pre-programmed with expectations about the calls they should receive and can throw exceptions when unexpected calls are made.
test('processRefund validates refund before calling payment gateway', async () => {
// Create a mock validation service
const validationService = {
validateRefund: jest.fn()
};
// Create a mock payment gateway
const paymentGateway = {
refundTransaction: jest.fn()
};
// Set up the mock to return that the refund is invalid
validationService.validateRefund.mockReturnValue(false);
// Try to process a refund
await refundService.processRefund({
orderId: 'order-123',
amount: 50,
reason: 'Customer request'
}, validationService, paymentGateway);
// Validation should be called
expect(validationService.validateRefund).toHaveBeenCalled();
// Payment gateway should NOT be called if validation fails
expect(paymentGateway.refundTransaction).not.toHaveBeenCalled();
});
Fake
Implements the same interface as the real object but in a simplified way, suitable for tests.
// A fake database that stores data in memory
class FakeUserDatabase {
constructor() {
this.users = new Map();
}
async findById(id) {
return this.users.get(id) || null;
}
async save(user) {
this.users.set(user.id, user);
return user;
}
async delete(id) {
return this.users.delete(id);
}
}
test('userService updates user profile correctly', async () => {
// Setup the fake database
const fakeDb = new FakeUserDatabase();
await fakeDb.save({ id: '123', name: 'Original Name', email: 'test@example.com' });
const userService = new UserService(fakeDb);
// Update the user
await userService.updateProfile('123', { name: 'New Name' });
// Verify the update was saved
const updatedUser = await fakeDb.findById('123');
expect(updatedUser.name).toBe('New Name');
expect(updatedUser.email).toBe('test@example.com'); // Unchanged
});
Choosing the Right Test Double
The type of test double to use depends on what you're testing:
- Use stubs when you need specific values to be returned
- Use spies when you want to verify function calls without changing behavior
- Use mocks when you need to verify complex interactions and strict expectations
- Use fakes for complex dependencies that need more realistic behavior than simple stubs
Common Testing Anti-Patterns
Avoid these common testing pitfalls that lead to brittle, slow, or misleading tests:
Testing Implementation Details
Tests should verify behavior, not how that behavior is implemented.
Bad Example
test('saveUser calls database with formatted user', () => {
const dbSpy = jest.spyOn(database, 'save');
const formatSpy = jest.spyOn(userFormatter, 'format');
userService.saveUser({ name: 'John', email: 'john@example.com' });
expect(formatSpy).toHaveBeenCalled();
expect(dbSpy).toHaveBeenCalled();
});
This test breaks if the implementation changes to use a different formatting method or database approach, even if the behavior remains correct.
Good Example
test('saveUser persists user data', async () => {
await userService.saveUser({ name: 'John', email: 'john@example.com' });
const savedUser = await userService.findUserByEmail('john@example.com');
expect(savedUser).toBeTruthy();
expect(savedUser.name).toBe('John');
});
This test verifies the behavior (user is saved and can be retrieved) without caring about the implementation details.
Over-Mocking
Too many mocks make tests complex and brittle.
Bad Example
test('checkout process', async () => {
// Excessive mocking
const cartServiceMock = { getItems: jest.fn(), clear: jest.fn() };
const inventoryServiceMock = { checkAvailability: jest.fn(), updateStock: jest.fn() };
const paymentServiceMock = { processPayment: jest.fn() };
const emailServiceMock = { sendConfirmation: jest.fn() };
const loggerMock = { log: jest.fn() };
cartServiceMock.getItems.mockReturnValue([{ id: 1, quantity: 2 }]);
inventoryServiceMock.checkAvailability.mockReturnValue(true);
paymentServiceMock.processPayment.mockReturnValue({ success: true });
const orderService = new OrderService(
cartServiceMock,
inventoryServiceMock,
paymentServiceMock,
emailServiceMock,
loggerMock
);
await orderService.checkout(userId, paymentDetails);
expect(cartServiceMock.getItems).toHaveBeenCalled();
expect(inventoryServiceMock.checkAvailability).toHaveBeenCalled();
expect(paymentServiceMock.processPayment).toHaveBeenCalled();
expect(emailServiceMock.sendConfirmation).toHaveBeenCalled();
expect(cartServiceMock.clear).toHaveBeenCalled();
});
Better Example
// Create a fake order processor that implements needed functionality
class TestOrderProcessor {
constructor() {
this.orders = [];
this.inventory = new Map([
[1, 10], // Item 1 has 10 units
[2, 5] // Item 2 has 5 units
]);
}
async checkout(userId, cart, paymentDetails) {
// Check inventory
for (const item of cart.items) {
if (!this.inventory.has(item.id) || this.inventory.get(item.id) < item.quantity) {
throw new Error('Insufficient inventory');
}
}
// "Process payment" (just validate it has required fields)
if (!paymentDetails.cardNumber || !paymentDetails.expiryDate) {
throw new Error('Invalid payment details');
}
// Create order
const order = {
id: `order-${Date.now()}`,
userId,
items: cart.items,
total: cart.items.reduce((sum, item) => sum + item.price * item.quantity, 0),
status: 'completed'
};
// Save order and update inventory
this.orders.push(order);
for (const item of cart.items) {
this.inventory.set(item.id, this.inventory.get(item.id) - item.quantity);
}
return order;
}
getOrder(orderId) {
return this.orders.find(order => order.id === orderId);
}
getInventory(itemId) {
return this.inventory.get(itemId);
}
}
test('checkout process creates order and updates inventory', async () => {
const processor = new TestOrderProcessor();
const cart = {
items: [
{ id: 1, name: 'Widget', price: 10, quantity: 2 }
]
};
const paymentDetails = {
cardNumber: '4111111111111111',
expiryDate: '12/25'
};
const order = await processor.checkout('user-123', cart, paymentDetails);
// Verify order was created
expect(order.status).toBe('completed');
expect(order.total).toBe(20);
// Verify inventory was updated
expect(processor.getInventory(1)).toBe(8);
});
This approach tests the behavior using a simplified implementation rather than excessive mocking.
Brittle Tests
Tests that break when unrelated things change create maintenance burden.
Bad Example
test('user profile renders correctly', () => {
const wrapper = render(<UserProfile user={user} />);
expect(wrapper.html()).toBe(
'<div class="profile"><h2>John Doe</h2><p>john@example.com</p></div>'
);
});
This test will break with any tiny change to the markup, even cosmetic ones like adding a CSS class.
Good Example
test('user profile displays name and email', () => {
const user = { name: 'John Doe', email: 'john@example.com' };
const { getByText } = render(<UserProfile user={user} />);
expect(getByText('John Doe')).toBeInTheDocument();
expect(getByText('john@example.com')).toBeInTheDocument();
});
This test verifies the important behavior without being coupled to the exact HTML structure.
Testing the Same Thing Multiple Times
Redundant tests slow down your test suite without adding value.
Bad Example
test('sum adds two numbers correctly - case 1', () => {
expect(sum(1, 2)).toBe(3);
});
test('sum adds two numbers correctly - case 2', () => {
expect(sum(3, 4)).toBe(7);
});
test('sum adds two numbers correctly - case 3', () => {
expect(sum(5, 6)).toBe(11);
});
Good Example
test('sum adds two numbers correctly', () => {
expect(sum(1, 2)).toBe(3);
expect(sum(3, 4)).toBe(7);
expect(sum(5, 6)).toBe(11);
});
Or even better, use parameterized tests:
test.each([
[1, 2, 3],
[3, 4, 7],
[5, 6, 11]
])('sum(%i, %i) = %i', (a, b, expected) => {
expect(sum(a, b)).toBe(expected);
});
Testing Error Handling
A robust application must handle errors gracefully. Testing error scenarios is just as important as testing successful operations.
Testing Expected Errors
function divide(a, b) {
if (b === 0) {
throw new Error("Cannot divide by zero");
}
return a / b;
}
test('divide throws error for division by zero', () => {
expect(() => divide(10, 0)).toThrow();
expect(() => divide(10, 0)).toThrow(/divide by zero/i);
});
Testing Async Error Handling
class UserRepository {
async findById(id) {
if (!id) {
throw new Error('User ID is required');
}
// Simulate database lookup
if (id === 'not-found') {
return null;
}
return { id, name: 'Test User' };
}
}
describe('UserRepository', () => {
let repository;
beforeEach(() => {
repository = new UserRepository();
});
test('findById returns user when found', async () => {
const user = await repository.findById('existing-id');
expect(user).toEqual({ id: 'existing-id', name: 'Test User' });
});
test('findById returns null for non-existent users', async () => {
const user = await repository.findById('not-found');
expect(user).toBeNull();
});
test('findById throws error when id is missing', async () => {
await expect(repository.findById()).rejects.toThrow('User ID is required');
});
});
Testing Error Recovery
class ApiClient {
constructor(maxRetries = 3) {
this.maxRetries = maxRetries;
}
async fetchWithRetry(url) {
let lastError;
for (let attempt = 1; attempt <= this.maxRetries; attempt++) {
try {
// Simulate fetch
if (url === 'https://api.example.com/success') {
return { data: 'success' };
}
if (url === 'https://api.example.com/fail-then-succeed') {
if (attempt < 3) {
throw new Error('Temporary failure');
}
return { data: 'success after retry' };
}
throw new Error('Permanent failure');
} catch (error) {
lastError = error;
// In a real implementation, we might wait before retrying
// await new Promise(resolve => setTimeout(resolve, 100 * attempt));
}
}
throw lastError;
}
}
describe('ApiClient', () => {
let apiClient;
beforeEach(() => {
apiClient = new ApiClient();
});
test('successfully fetches data on first attempt', async () => {
const result = await apiClient.fetchWithRetry('https://api.example.com/success');
expect(result).toEqual({ data: 'success' });
});
test('succeeds after multiple retries', async () => {
const result = await apiClient.fetchWithRetry('https://api.example.com/fail-then-succeed');
expect(result).toEqual({ data: 'success after retry' });
});
test('fails after exhausting all retries', async () => {
await expect(apiClient.fetchWithRetry('https://api.example.com/always-fail'))
.rejects.toThrow('Permanent failure');
});
test('respects custom retry count', async () => {
const clientWithOneRetry = new ApiClient(1);
await expect(clientWithOneRetry.fetchWithRetry('https://api.example.com/fail-then-succeed'))
.rejects.toThrow('Temporary failure');
});
});
Testing Strategies for Different Types of Code
Testing Pure Functions
- Focus on input-output relationships
- Test edge cases thoroughly
- Use parameterized tests for multiple inputs
- No need for mocks or complex setup
// Pure function
function calculateTotalPrice(items, taxRate) {
const subtotal = items.reduce((sum, item) => sum + (item.price * item.quantity), 0);
return subtotal * (1 + taxRate / 100);
}
// Tests
describe('calculateTotalPrice', () => {
test('calculates total correctly with tax', () => {
const items = [
{ name: 'Product 1', price: 10, quantity: 2 },
{ name: 'Product 2', price: 20, quantity: 1 }
];
expect(calculateTotalPrice(items, 10)).toBe(44); // (10*2 + 20*1) * 1.1
});
test('handles empty cart', () => {
expect(calculateTotalPrice([], 10)).toBe(0);
});
test('handles zero tax', () => {
const items = [{ name: 'Product', price: 10, quantity: 1 }];
expect(calculateTotalPrice(items, 0)).toBe(10);
});
});
Testing Classes and Objects
- Test both behavior and state
- Test interactions between methods
- Create fresh instances for each test
- Test public API, not private implementation
class ShoppingCart {
constructor() {
this.items = [];
}
addItem(product, quantity = 1) {
const existingItem = this.items.find(item => item.productId === product.id);
if (existingItem) {
existingItem.quantity += quantity;
} else {
this.items.push({
productId: product.id,
name: product.name,
price: product.price,
quantity
});
}
}
removeItem(productId) {
const index = this.items.findIndex(item => item.productId === productId);
if (index !== -1) {
this.items.splice(index, 1);
return true;
}
return false;
}
updateQuantity(productId, quantity) {
if (quantity <= 0) {
return this.removeItem(productId);
}
const item = this.items.find(item => item.productId === productId);
if (item) {
item.quantity = quantity;
return true;
}
return false;
}
getTotal() {
return this.items.reduce((total, item) => {
return total + (item.price * item.quantity);
}, 0);
}
clear() {
this.items = [];
}
getItemCount() {
return this.items.reduce((count, item) => count + item.quantity, 0);
}
}
// Tests
describe('ShoppingCart', () => {
let cart;
let product1;
let product2;
beforeEach(() => {
cart = new ShoppingCart();
product1 = { id: 'p1', name: 'Product 1', price: 10 };
product2 = { id: 'p2', name: 'Product 2', price: 20 };
});
test('starts empty', () => {
expect(cart.items).toHaveLength(0);
expect(cart.getTotal()).toBe(0);
expect(cart.getItemCount()).toBe(0);
});
describe('addItem', () => {
test('adds new item to cart', () => {
cart.addItem(product1);
expect(cart.items).toHaveLength(1);
expect(cart.items[0]).toEqual({
productId: 'p1',
name: 'Product 1',
price: 10,
quantity: 1
});
});
test('increases quantity for existing item', () => {
cart.addItem(product1);
cart.addItem(product1);
expect(cart.items).toHaveLength(1);
expect(cart.items[0].quantity).toBe(2);
});
test('adds multiple items with custom quantities', () => {
cart.addItem(product1, 3);
cart.addItem(product2, 2);
expect(cart.items).toHaveLength(2);
expect(cart.getItemCount()).toBe(5);
expect(cart.getTotal()).toBe(70); // 3*10 + 2*20
});
});
describe('removeItem', () => {
test('removes item from cart', () => {
cart.addItem(product1);
cart.addItem(product2);
const result = cart.removeItem('p1');
expect(result).toBe(true);
expect(cart.items).toHaveLength(1);
expect(cart.items[0].productId).toBe('p2');
});
test('returns false when removing non-existent item', () => {
const result = cart.removeItem('nonexistent');
expect(result).toBe(false);
expect(cart.items).toHaveLength(0);
});
});
describe('updateQuantity', () => {
beforeEach(() => {
cart.addItem(product1, 2);
});
test('updates item quantity', () => {
const result = cart.updateQuantity('p1', 5);
expect(result).toBe(true);
expect(cart.items[0].quantity).toBe(5);
expect(cart.getTotal()).toBe(50);
});
test('removes item when setting quantity to zero', () => {
const result = cart.updateQuantity('p1', 0);
expect(result).toBe(true);
expect(cart.items).toHaveLength(0);
});
test('removes item when setting negative quantity', () => {
const result = cart.updateQuantity('p1', -1);
expect(result).toBe(true);
expect(cart.items).toHaveLength(0);
});
test('returns false for non-existent item', () => {
const result = cart.updateQuantity('nonexistent', 5);
expect(result).toBe(false);
});
});
describe('clear', () => {
test('removes all items from cart', () => {
cart.addItem(product1);
cart.addItem(product2);
cart.clear();
expect(cart.items).toHaveLength(0);
expect(cart.getTotal()).toBe(0);
expect(cart.getItemCount()).toBe(0);
});
});
});
Testing Async Code and APIs
- Use async/await for clean test code
- Mock external dependencies
- Test success and failure scenarios
- Verify retry logic and timeouts
class ProductService {
constructor(apiClient) {
this.apiClient = apiClient;
}
async getProducts(category) {
try {
const url = category
? `/products?category=${encodeURIComponent(category)}`
: '/products';
const response = await this.apiClient.get(url);
return response.data;
} catch (error) {
if (error.status === 404) {
return [];
}
throw new Error(`Failed to fetch products: ${error.message}`);
}
}
async getProductDetails(productId) {
if (!productId) {
throw new Error('Product ID is required');
}
try {
const response = await this.apiClient.get(`/products/${productId}`);
return response.data;
} catch (error) {
if (error.status === 404) {
return null;
}
throw new Error(`Failed to fetch product details: ${error.message}`);
}
}
}
// Tests
describe('ProductService', () => {
let productService;
let mockApiClient;
beforeEach(() => {
mockApiClient = {
get: jest.fn()
};
productService = new ProductService(mockApiClient);
});
describe('getProducts', () => {
test('fetches all products when no category specified', async () => {
const mockProducts = [{ id: 1, name: 'Product 1' }];
mockApiClient.get.mockResolvedValue({ data: mockProducts });
const result = await productService.getProducts();
expect(mockApiClient.get).toHaveBeenCalledWith('/products');
expect(result).toEqual(mockProducts);
});
test('fetches products by category', async () => {
const mockProducts = [{ id: 1, name: 'Product 1', category: 'electronics' }];
mockApiClient.get.mockResolvedValue({ data: mockProducts });
const result = await productService.getProducts('electronics');
expect(mockApiClient.get).toHaveBeenCalledWith('/products?category=electronics');
expect(result).toEqual(mockProducts);
});
test('encodes category parameter correctly', async () => {
mockApiClient.get.mockResolvedValue({ data: [] });
await productService.getProducts('home & garden');
expect(mockApiClient.get).toHaveBeenCalledWith('/products?category=home%20%26%20garden');
});
test('returns empty array for 404 response', async () => {
const error = new Error('Not found');
error.status = 404;
mockApiClient.get.mockRejectedValue(error);
const result = await productService.getProducts();
expect(result).toEqual([]);
});
test('throws for other errors', async () => {
const error = new Error('Network error');
error.status = 500;
mockApiClient.get.mockRejectedValue(error);
await expect(productService.getProducts()).rejects.toThrow('Failed to fetch products');
});
});
describe('getProductDetails', () => {
test('fetches product details by id', async () => {
const mockProduct = { id: 1, name: 'Product 1', description: 'Description' };
mockApiClient.get.mockResolvedValue({ data: mockProduct });
const result = await productService.getProductDetails(1);
expect(mockApiClient.get).toHaveBeenCalledWith('/products/1');
expect(result).toEqual(mockProduct);
});
test('throws error when product id is missing', async () => {
await expect(productService.getProductDetails()).rejects.toThrow('Product ID is required');
expect(mockApiClient.get).not.toHaveBeenCalled();
});
test('returns null for non-existent product', async () => {
const error = new Error('Not found');
error.status = 404;
mockApiClient.get.mockRejectedValue(error);
const result = await productService.getProductDetails(999);
expect(result).toBeNull();
});
});
});
Test-Driven Development in Practice
Let's work through a complete TDD example to see the Red-Green-Refactor cycle in action.
Problem: Create a Password Validator
We need to create a password validator with the following requirements:
- Password must be at least 8 characters long
- Password must contain at least one uppercase letter
- Password must contain at least one lowercase letter
- Password must contain at least one number
- Password must contain at least one special character (!@#$%^&*)
Step 1: Write the First Test (Red)
// password-validator.test.js
describe('PasswordValidator', () => {
let validator;
beforeEach(() => {
validator = new PasswordValidator();
});
test('rejects passwords shorter than 8 characters', () => {
const result = validator.validate('Abc123!');
expect(result.isValid).toBe(false);
expect(result.errors).toContain('Password must be at least 8 characters long');
});
});
This test fails because we haven't created the PasswordValidator class yet.
Step 2: Write Minimal Implementation (Green)
// password-validator.js
class PasswordValidator {
validate(password) {
const errors = [];
if (password.length < 8) {
errors.push('Password must be at least 8 characters long');
}
return {
isValid: errors.length === 0,
errors
};
}
}
module.exports = PasswordValidator;
Now the test passes, but we're only checking password length.
Step 3: Write the Next Test (Red)
test('requires at least one uppercase letter', () => {
const result = validator.validate('abcdef123!');
expect(result.isValid).toBe(false);
expect(result.errors).toContain('Password must contain at least one uppercase letter');
});
Step 4: Update Implementation (Green)
validate(password) {
const errors = [];
if (password.length < 8) {
errors.push('Password must be at least 8 characters long');
}
if (!/[A-Z]/.test(password)) {
errors.push('Password must contain at least one uppercase letter');
}
return {
isValid: errors.length === 0,
errors
};
}
Step 5: Continue Adding Tests and Implementation
test('requires at least one lowercase letter', () => {
const result = validator.validate('ABCDEF123!');
expect(result.isValid).toBe(false);
expect(result.errors).toContain('Password must contain at least one lowercase letter');
});
test('requires at least one number', () => {
const result = validator.validate('ABCDEFabc!');
expect(result.isValid).toBe(false);
expect(result.errors).toContain('Password must contain at least one number');
});
test('requires at least one special character', () => {
const result = validator.validate('ABCDEFabc123');
expect(result.isValid).toBe(false);
expect(result.errors).toContain('Password must contain at least one special character');
});
validate(password) {
const errors = [];
if (password.length < 8) {
errors.push('Password must be at least 8 characters long');
}
if (!/[A-Z]/.test(password)) {
errors.push('Password must contain at least one uppercase letter');
}
if (!/[a-z]/.test(password)) {
errors.push('Password must contain at least one lowercase letter');
}
if (!/[0-9]/.test(password)) {
errors.push('Password must contain at least one number');
}
if (!/[!@#$%^&*]/.test(password)) {
errors.push('Password must contain at least one special character');
}
return {
isValid: errors.length === 0,
errors
};
}
Step 6: Test Valid Passwords
test('accepts valid passwords', () => {
const result = validator.validate('Abcdef123!');
expect(result.isValid).toBe(true);
expect(result.errors).toHaveLength(0);
});
Step 7: Refactor Code
class PasswordValidator {
validate(password) {
const rules = [
{
test: pwd => pwd.length >= 8,
message: 'Password must be at least 8 characters long'
},
{
test: pwd => /[A-Z]/.test(pwd),
message: 'Password must contain at least one uppercase letter'
},
{
test: pwd => /[a-z]/.test(pwd),
message: 'Password must contain at least one lowercase letter'
},
{
test: pwd => /[0-9]/.test(pwd),
message: 'Password must contain at least one number'
},
{
test: pwd => /[!@#$%^&*]/.test(pwd),
message: 'Password must contain at least one special character'
}
];
const errors = rules
.filter(rule => !rule.test(password))
.map(rule => rule.message);
return {
isValid: errors.length === 0,
errors
};
}
}
module.exports = PasswordValidator;
This refactored implementation is more maintainable and follows the DRY principle, but it still passes all tests.
Final Test Suite
describe('PasswordValidator', () => {
let validator;
beforeEach(() => {
validator = new PasswordValidator();
});
test('rejects passwords shorter than 8 characters', () => {
const result = validator.validate('Abc123!');
expect(result.isValid).toBe(false);
expect(result.errors).toContain('Password must be at least 8 characters long');
});
test('requires at least one uppercase letter', () => {
const result = validator.validate('abcdef123!');
expect(result.isValid).toBe(false);
expect(result.errors).toContain('Password must contain at least one uppercase letter');
});
test('requires at least one lowercase letter', () => {
const result = validator.validate('ABCDEF123!');
expect(result.isValid).toBe(false);
expect(result.errors).toContain('Password must contain at least one lowercase letter');
});
test('requires at least one number', () => {
const result = validator.validate('ABCDEFabc!');
expect(result.isValid).toBe(false);
expect(result.errors).toContain('Password must contain at least one number');
});
test('requires at least one special character', () => {
const result = validator.validate('ABCDEFabc123');
expect(result.isValid).toBe(false);
expect(result.errors).toContain('Password must contain at least one special character');
});
test('accepts valid passwords', () => {
const result = validator.validate('Abcdef123!');
expect(result.isValid).toBe(true);
expect(result.errors).toHaveLength(0);
});
test('returns all errors for invalid passwords', () => {
const result = validator.validate('a');
expect(result.errors).toHaveLength(4);
});
});
TDD Benefits Demonstrated
Through this example, we can see several TDD benefits:
- Focused Development: We built only what we needed, one requirement at a time
- High Test Coverage: We have 100% test coverage by definition
- Clean Design: The refactoring step led to a more maintainable implementation
- Documentation: The tests clearly document all requirements
- Confidence in Refactoring: We could refactor with confidence because the tests verified that behavior stayed the same
Practice Activities
Activity 1: Test a URL Shortener Service
Implement and test a URL shortener service with the following features:
- Generate a short code for a given URL
- Retrieve the original URL using the short code
- Handle invalid URLs
- Check for expired short codes
Use TDD to implement the service one requirement at a time.
Activity 2: Refactor and Test Legacy Code
Given a poorly written utility function, write tests for it and then refactor it to be more maintainable:
function formatPhoneNumber(input) {
var output = "";
for (var i = 0; i < input.length; i++) {
if (input[i] >= "0" && input[i] <= "9") {
output += input[i];
}
}
if (output.length == 10) {
return "(" + output.substring(0, 3) + ") " + output.substring(3, 6) + "-" + output.substring(6);
} else if (output.length == 11 && output[0] == "1") {
return "(" + output.substring(1, 4) + ") " + output.substring(4, 7) + "-" + output.substring(7);
} else {
return input;
}
}
Create tests that verify the current behavior, and then refactor the function to be more maintainable.
Activity 3: Create a Testing Strategy for a Blog Application
Design a testing strategy for a blog application with the following components:
- User authentication service
- Post management (create, update, delete)
- Comments and reactions
- Search functionality
- Admin dashboard
For each component:
- Identify what types of tests would be most appropriate
- List key test cases to verify
- Describe how you would mock dependencies
- Identify edge cases to test