正在加载,请稍候…

Test-Driven Development: Red-Green-Refactor in Practice

Learn TDD with the red-green-refactor cycle. Write better tests and design by working through real examples in TypeScript with Jest.

Test-Driven Development: Red-Green-Refactor in Practice

TDD means writing tests before code, driving design through tests.

The TDD Cycle

  1. Red: Write a failing test
  2. Green: Write minimum code to pass
  3. Refactor: Improve code while keeping tests green

Example: Building a Shopping Cart

Step 1: Red - Write Failing Tests

// cart.test.ts
import { ShoppingCart } from './cart';

describe('ShoppingCart', () => {
  let cart: ShoppingCart;

  beforeEach(() => {
    cart = new ShoppingCart();
  });

  test('starts empty', () => {
    expect(cart.itemCount).toBe(0);
    expect(cart.total).toBe(0);
  });

  test('adds items', () => {
    cart.add({ id: '1', name: 'Apple', price: 1.50 });
    expect(cart.itemCount).toBe(1);
  });

  test('calculates total', () => {
    cart.add({ id: '1', name: 'Apple', price: 1.50 });
    cart.add({ id: '2', name: 'Banana', price: 0.75 });
    expect(cart.total).toBeCloseTo(2.25);
  });

  test('applies discount for items over 5', () => {
    for (let i = 0; i < 6; i++) {
      cart.add({ id: `${i}`, name: `Item ${i}`, price: 10 });
    }
    expect(cart.total).toBeLessThan(60); // discount applied
  });
});

Step 2: Green - Minimal Implementation

// cart.ts
interface Product {
  id: string;
  name: string;
  price: number;
}

export class ShoppingCart {
  private items: Product[] = [];

  add(product: Product): void {
    this.items.push(product);
  }

  get itemCount(): number {
    return this.items.length;
  }

  get total(): number {
    const subtotal = this.items.reduce((sum, item) => sum + item.price, 0);
    return this.items.length > 5 ? subtotal * 0.9 : subtotal;
  }
}

Step 3: Refactor

// After tests pass, improve the design
export class ShoppingCart {
  private items: Map<string, { product: Product; quantity: number }> = new Map();

  add(product: Product, quantity = 1): void {
    const existing = this.items.get(product.id);
    if (existing) {
      existing.quantity += quantity;
    } else {
      this.items.set(product.id, { product, quantity });
    }
  }

  remove(productId: string): void {
    this.items.delete(productId);
  }

  get itemCount(): number {
    return Array.from(this.items.values())
      .reduce((sum, { quantity }) => sum + quantity, 0);
  }

  get subtotal(): number {
    return Array.from(this.items.values())
      .reduce((sum, { product, quantity }) => sum + product.price * quantity, 0);
  }

  get total(): number {
    return this.itemCount > 5 ? this.subtotal * 0.9 : this.subtotal;
  }
}

Testing Boundaries

// Test at the right level - avoid over-mocking
describe('UserRegistrationService', () => {
  // Use real in-memory repository, not mocked
  let userRepo: InMemoryUserRepository;
  let service: UserRegistrationService;

  beforeEach(() => {
    userRepo = new InMemoryUserRepository();
    service = new UserRegistrationService(userRepo, new FakeEmailService());
  });

  test('registers new user', async () => {
    const result = await service.register('alice@example.com', 'password');
    expect(result.success).toBe(true);
    const user = await userRepo.findByEmail('alice@example.com');
    expect(user).toBeDefined();
  });

  test('rejects duplicate email', async () => {
    await service.register('alice@example.com', 'password');
    const result = await service.register('alice@example.com', 'other');
    expect(result.success).toBe(false);
    expect(result.error).toContain('already exists');
  });
});

TDD for Refactoring

// First: write characterization tests for existing code
test('current behavior (before refactor)', () => {
  const result = legacyCalculate(10, 5);
  expect(result).toBe(42); // document what it actually does
});

// Then refactor with confidence

TDD produces code that is both well-tested and well-designed.