The Importance of Test Strategy
An appropriate test strategy is essential for ensuring software quality. Efficient test design enables sustainable development while balancing development speed and quality.
| Purpose | Benefits |
|---|
| 1. Quality Assurance | Early bug detection, Regression prevention, Specification verification |
| 2. Design Improvement | Improved testability, Promotes loose coupling, Interface clarification |
| 3. Documentation | Executable specifications, Usage examples, Boundary conditions made explicit |
Test Pyramid
flowchart TB
subgraph Pyramid["Test Pyramid"]
E2E["E2E Tests<br/>(Few, High Cost)"]
Integration["Integration Tests<br/>(Moderate)"]
Unit["Unit Tests<br/>(Many, Low Cost)"]
E2E --> Integration --> Unit
end
| Level | Speed | Reliability | Cost |
|---|
| E2E | Slow | Low | High |
| Integration | Moderate | Moderate | Moderate |
| Unit | Fast | High | Low |
Unit Tests
Basic Principles (F.I.R.S.T)
import { describe, it, expect, beforeEach } from 'vitest';
function calculateTax(price: number, taxRate: number): number {
if (price < 0) throw new Error('Price cannot be negative');
if (taxRate < 0 || taxRate > 1) throw new Error('Invalid tax rate');
return Math.round(price * taxRate);
}
describe('calculateTax', () => {
it('should calculate tax correctly', () => {
expect(calculateTax(1000, 0.1)).toBe(100);
});
it('should round to nearest integer', () => {
expect(calculateTax(999, 0.1)).toBe(100);
});
it('should return 0 for zero price', () => {
expect(calculateTax(0, 0.1)).toBe(0);
});
it('should handle zero tax rate', () => {
expect(calculateTax(1000, 0)).toBe(0);
});
it('should handle 100% tax rate', () => {
expect(calculateTax(1000, 1)).toBe(1000);
});
it('should throw for negative price', () => {
expect(() => calculateTax(-100, 0.1)).toThrow('Price cannot be negative');
});
it('should throw for invalid tax rate', () => {
expect(() => calculateTax(1000, 1.5)).toThrow('Invalid tax rate');
expect(() => calculateTax(1000, -0.1)).toThrow('Invalid tax rate');
});
});
AAA Pattern
import { describe, it, expect } from 'vitest';
class ShoppingCart {
private items: Array<{ name: string; price: number; quantity: number }> = [];
addItem(name: string, price: number, quantity: number = 1): void {
this.items.push({ name, price, quantity });
}
removeItem(name: string): void {
this.items = this.items.filter(item => item.name !== name);
}
getTotal(): number {
return this.items.reduce((sum, item) => sum + item.price * item.quantity, 0);
}
getItemCount(): number {
return this.items.reduce((sum, item) => sum + item.quantity, 0);
}
}
describe('ShoppingCart', () => {
it('should calculate total correctly', () => {
const cart = new ShoppingCart();
cart.addItem('Apple', 100, 3);
cart.addItem('Banana', 80, 2);
const total = cart.getTotal();
expect(total).toBe(460);
});
it('should remove item correctly', () => {
const cart = new ShoppingCart();
cart.addItem('Apple', 100, 1);
cart.addItem('Banana', 80, 1);
cart.removeItem('Apple');
expect(cart.getTotal()).toBe(80);
expect(cart.getItemCount()).toBe(1);
});
});
Test Doubles
| Type | Description |
|---|
| Stub | Returns predefined values, Simulates external dependencies, Controls only input/output |
| Mock | Verifies expected calls, Confirms call count and arguments, Used for behavior verification |
| Spy | Records calls while using real impl, Can verify calls afterwards, Partial mocking |
| Fake | Simplified implementation (e.g., in-memory DB), Same interface as production |
Test Double Implementation Example
import { describe, it, expect, vi, beforeEach } from 'vitest';
interface EmailService {
send(to: string, subject: string, body: string): Promise<boolean>;
}
interface UserRepository {
findById(id: string): Promise<User | null>;
save(user: User): Promise<void>;
}
interface User {
id: string;
email: string;
name: string;
}
class UserService {
constructor(
private userRepo: UserRepository,
private emailService: EmailService
) {}
async notifyUser(userId: string, message: string): Promise<boolean> {
const user = await this.userRepo.findById(userId);
if (!user) {
throw new Error('User not found');
}
return this.emailService.send(
user.email,
'Notification',
message
);
}
}
describe('UserService', () => {
let userRepo: UserRepository;
let emailService: EmailService;
let userService: UserService;
beforeEach(() => {
userRepo = {
findById: vi.fn(),
save: vi.fn(),
};
emailService = {
send: vi.fn(),
};
userService = new UserService(userRepo, emailService);
});
it('should send notification to user', async () => {
const mockUser: User = {
id: '1',
email: 'test@example.com',
name: 'Test User',
};
vi.mocked(userRepo.findById).mockResolvedValue(mockUser);
vi.mocked(emailService.send).mockResolvedValue(true);
const result = await userService.notifyUser('1', 'Hello!');
expect(result).toBe(true);
expect(emailService.send).toHaveBeenCalledWith(
'test@example.com',
'Notification',
'Hello!'
);
expect(emailService.send).toHaveBeenCalledTimes(1);
});
it('should throw error when user not found', async () => {
vi.mocked(userRepo.findById).mockResolvedValue(null);
await expect(userService.notifyUser('999', 'Hello!'))
.rejects.toThrow('User not found');
expect(emailService.send).not.toHaveBeenCalled();
});
});
class FakeUserRepository implements UserRepository {
private users: Map<string, User> = new Map();
async findById(id: string): Promise<User | null> {
return this.users.get(id) ?? null;
}
async save(user: User): Promise<void> {
this.users.set(user.id, user);
}
seed(users: User[]): void {
users.forEach(user => this.users.set(user.id, user));
}
clear(): void {
this.users.clear();
}
}
TDD (Test-Driven Development)
flowchart TB
subgraph TDD["TDD Cycle (Red-Green-Refactor)"]
Red["RED<br/>(Fail)"]
Green["GREEN<br/>(Pass)"]
Refactor["REFACTOR<br/>(Improve)"]
Red -->|"Write a failing test"| Green
Green -->|"Minimal code to pass"| Refactor
Refactor -->|"Improve the code"| Red
end
Coverage Strategy
| Coverage Type | Description |
|---|
| Statement Coverage | Whether each line was executed |
| Branch Coverage | Whether each branch (if/else) was executed |
| Function Coverage | Whether each function was called |
| Line Coverage | Whether each line was executed |
Recommended Targets:
| Category | Target |
|---|
| Overall | 80% or higher |
| Critical business logic | 90% or higher |
| Utility functions | 100% |
| UI components | 70% or higher |
Note: Coverage is just one quality metric. Bugs can exist even at 100%.
Best Practices
| Category | Best Practice |
|---|
| Naming | Test names should be “should + expected behavior” |
| Structure | Use AAA pattern (Arrange-Act-Assert) |
| Independence | Eliminate dependencies between tests |
| Speed | Keep unit tests fast |
| Reliability | Eliminate flaky tests |
| Maintainability | Manage test code like production code |
Reference Links
← Back to list