Test Strategy Design Patterns - From Test Pyramid to Practical Test Design

2025.12.02

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.

PurposeBenefits
1. Quality AssuranceEarly bug detection, Regression prevention, Specification verification
2. Design ImprovementImproved testability, Promotes loose coupling, Interface clarification
3. DocumentationExecutable 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
LevelSpeedReliabilityCost
E2ESlowLowHigh
IntegrationModerateModerateModerate
UnitFastHighLow

Unit Tests

Basic Principles (F.I.R.S.T)

// Unit tests following F.I.R.S.T principles

// Fast - Tests should be quick to run
// Independent - No dependencies between tests
// Repeatable - Same result every time
// Self-Validating - Clear pass/fail
// Timely - Written before or after production code

import { describe, it, expect, beforeEach } from 'vitest';

// Utility function under test
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', () => {
  // Happy path
  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); // 99.9 → 100
  });

  // Boundary value tests
  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);
  });

  // Error cases
  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

// Arrange-Act-Assert 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', () => {
    // Arrange
    const cart = new ShoppingCart();
    cart.addItem('Apple', 100, 3);
    cart.addItem('Banana', 80, 2);

    // Act
    const total = cart.getTotal();

    // Assert
    expect(total).toBe(460); // 100*3 + 80*2
  });

  it('should remove item correctly', () => {
    // Arrange
    const cart = new ShoppingCart();
    cart.addItem('Apple', 100, 1);
    cart.addItem('Banana', 80, 1);

    // Act
    cart.removeItem('Apple');

    // Assert
    expect(cart.getTotal()).toBe(80);
    expect(cart.getItemCount()).toBe(1);
  });
});

Test Doubles

TypeDescription
StubReturns predefined values, Simulates external dependencies, Controls only input/output
MockVerifies expected calls, Confirms call count and arguments, Used for behavior verification
SpyRecords calls while using real impl, Can verify calls afterwards, Partial mocking
FakeSimplified implementation (e.g., in-memory DB), Same interface as production

Test Double Implementation Example

import { describe, it, expect, vi, beforeEach } from 'vitest';

// Dependency interfaces
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 under test
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(() => {
    // Create Stub
    userRepo = {
      findById: vi.fn(),
      save: vi.fn(),
    };

    // Create Mock
    emailService = {
      send: vi.fn(),
    };

    userService = new UserService(userRepo, emailService);
  });

  it('should send notification to user', async () => {
    // Stub setup (return predefined value)
    const mockUser: User = {
      id: '1',
      email: 'test@example.com',
      name: 'Test User',
    };
    vi.mocked(userRepo.findById).mockResolvedValue(mockUser);
    vi.mocked(emailService.send).mockResolvedValue(true);

    // Execute
    const result = await userService.notifyUser('1', 'Hello!');

    // Verify result
    expect(result).toBe(true);

    // Mock verification (confirm calls)
    expect(emailService.send).toHaveBeenCalledWith(
      'test@example.com',
      'Notification',
      'Hello!'
    );
    expect(emailService.send).toHaveBeenCalledTimes(1);
  });

  it('should throw error when user not found', async () => {
    // Stub setup (return null)
    vi.mocked(userRepo.findById).mockResolvedValue(null);

    // Exception verification
    await expect(userService.notifyUser('999', 'Hello!'))
      .rejects.toThrow('User not found');

    // Confirm emailService was not called
    expect(emailService.send).not.toHaveBeenCalled();
  });
});

// Fake example (in-memory repository)
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);
  }

  // Helper methods for testing
  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 TypeDescription
Statement CoverageWhether each line was executed
Branch CoverageWhether each branch (if/else) was executed
Function CoverageWhether each function was called
Line CoverageWhether each line was executed

Recommended Targets:

CategoryTarget
Overall80% or higher
Critical business logic90% or higher
Utility functions100%
UI components70% or higher

Note: Coverage is just one quality metric. Bugs can exist even at 100%.

Best Practices

CategoryBest Practice
NamingTest names should be “should + expected behavior”
StructureUse AAA pattern (Arrange-Act-Assert)
IndependenceEliminate dependencies between tests
SpeedKeep unit tests fast
ReliabilityEliminate flaky tests
MaintainabilityManage test code like production code
← Back to list