Lesson 09

Test-Driven Development

Red, Green, Refactor — let tests drive your design

Red-Green-Refactor Testing Pyramid AAA Pattern Mocking Test Naming Commit Separation
💡 Agile Framework Integration

Use /agile-code-tdd to scaffold a TDD session. The command prompts you for the feature requirements and guides you through the Red-Green-Refactor cycle. It pairs naturally with /agile-code-branch (create the feature branch first) and /agile-code-ci (verify all tests pass before committing).

The TDD Cycle

TDD is a discipline: you write the test before the code. Every feature starts as a failing test. The cycle is simple and repeats continuously.

🔴
RED Write a failing test (defines the behavior)
🟢
GREEN Write the minimum code to make it pass
🔵
REFACTOR Clean up the code (tests still pass)
💡 The Golden Rule

Never write production code without a failing test that demands it. The test comes first — always.

Why TDD Works

The Testing Pyramid

🔺
E2E Tests Few, slow, expensive -- test full user journeys
🔶
Integration Tests Some, moderate speed -- test component boundaries
🟩
Unit Tests Many, fast, cheap -- test single functions/classes
LevelCountSpeedScopeExample
UnitHundredsMillisecondsSingle function/classcalculateDiscount returns correct value
IntegrationDozensSecondsModule boundariesOrderService saves to database correctly
E2EHandfulMinutesFull user journeyUser can sign up, browse, and checkout
⚠️ Inverted Pyramid = Pain

Teams that rely mostly on E2E tests have slow CI pipelines, flaky results, and painful debugging. Invest heavily in unit tests. Use E2E tests sparingly for critical user flows.

Test Naming Convention

A test name should describe the behavior, not the implementation. Use the pattern:

should_[expected behavior]_when_[condition]

Examples

// Clear and descriptive
should_return_zero_when_cart_is_empty()
should_apply_discount_when_coupon_is_valid()
should_throw_error_when_email_is_invalid()
should_send_notification_when_order_is_placed()

// NOT this:
test1()
testCalculate()
testUserStuff()

The AAA Pattern

Every test follows three distinct phases. Keep them visually separated.

Arrange → Act → Assert

describe('PriceCalculator', () => {
  it('should_apply_percentage_discount_when_coupon_is_valid', () => {
    // Arrange — set up the test scenario
    const calculator = new PriceCalculator();
    const items = [
      { name: 'Widget', price: 100, quantity: 2 },
      { name: 'Gadget', price: 50, quantity: 1 },
    ];
    const coupon = { type: 'percentage', value: 10 };

    // Act — perform the action under test
    const total = calculator.calculateTotal(items, coupon);

    // Assert — verify the outcome
    expect(total).toBe(225); // (200 + 50) - 10% = 225
  });
});
✅ One Assert Per Test

Each test should verify one behavior. Multiple unrelated assertions in a single test make failures hard to diagnose. If a test name uses "and," split it into two tests.

Mocking: Boundaries, Not Internals

Mock at the edges of your system — databases, APIs, file systems. Never mock the class under test or its internal collaborators.

❌ Bad — Over-Mocking

// Mocking internal helpers defeats the purpose of testing
jest.mock('./calculateTax');
jest.mock('./formatCurrency');
jest.mock('./validateInput');

// You're testing nothing but mock wiring

✅ Good — Mock at Boundaries

// Mock only external dependencies
const mockDatabase = { find: jest.fn(), save: jest.fn() };
const mockEmailService = { send: jest.fn() };

const service = new OrderService(mockDatabase, mockEmailService);

it('should_save_order_and_notify_user', async () => {
  mockDatabase.save.mockResolvedValue({ id: 'order-1' });

  await service.placeOrder(orderData);

  expect(mockDatabase.save).toHaveBeenCalledWith(orderData);
  expect(mockEmailService.send).toHaveBeenCalled();
});

TDD and Commit Discipline

TDD naturally creates two types of commits. Keep them separate to enable safe reverts and clear code review.

Commit TypePrefixContains
Behavioralfeat: / fix:New functionality + its tests
Structuralrefactor:Code reorganization, no behavior change
🚫 Never Mix

A refactor: commit should pass the exact same tests as before. A feat: commit should add new tests. Mixing them makes it impossible to tell if a regression came from the refactor or the feature.

✅ TDD in the Full Story Workflow

TDD fits into the complete story-to-code workflow. Each command prompts you for the required inputs:

/agile-code-branch/agile-code-tdd/agile-code-ci/agile-code-commit/agile-code-pr/agile-code-pr-review/agile-code-merge/agile-story-dod/agile-story-accept

TDD Walkthrough: Building a Password Validator

Let's walk through TDD step by step, building a simple feature.

🔴 Step 1: Red — Write the Failing Test

describe('PasswordValidator', () => {
  it('should_reject_when_password_is_shorter_than_8_chars', () => {
    const validator = new PasswordValidator();
    const result = validator.validate('abc');
    expect(result.valid).toBe(false);
    expect(result.errors).toContain('Must be at least 8 characters');
  });
});

// Run: FAIL ✘ — PasswordValidator is not defined

🟢 Step 2: Green — Minimum Code to Pass

class PasswordValidator {
  validate(password) {
    const errors = [];
    if (password.length < 8) {
      errors.push('Must be at least 8 characters');
    }
    return { valid: errors.length === 0, errors };
  }
}

// Run: PASS ✔ — Test passes!

🔴 Step 3: Red — Add Next Requirement

it('should_reject_when_password_has_no_uppercase', () => {
  const validator = new PasswordValidator();
  const result = validator.validate('abcdefgh');
  expect(result.valid).toBe(false);
  expect(result.errors).toContain('Must contain an uppercase letter');
});

// Run: FAIL ✘ — No uppercase check yet

🟢 Step 4: Green — Make It Pass

validate(password) {
  const errors = [];
  if (password.length < 8)
    errors.push('Must be at least 8 characters');
  if (!/[A-Z]/.test(password))
    errors.push('Must contain an uppercase letter');
  return { valid: errors.length === 0, errors };
}

// Run: PASS ✔ — Both tests pass!

🔵 Step 5: Refactor — Clean Up

class PasswordValidator {
  #rules = [
    { test: pw => pw.length >= 8, msg: 'Must be at least 8 characters' },
    { test: pw => /[A-Z]/.test(pw), msg: 'Must contain an uppercase letter' },
  ];

  validate(password) {
    const errors = this.#rules
      .filter(rule => !rule.test(password))
      .map(rule => rule.msg);
    return { valid: errors.length === 0, errors };
  }
}

// Run: PASS ✔ — All tests still pass! Now it's extensible.
✅ The Refactor Step

Notice how the refactor made the code open for extension (adding rules) without modifying the validate method. TDD naturally leads to SOLID code. The tests gave us the confidence to restructure safely.

🧠 Knowledge Check

In TDD, what do you write first?