Master TDD's Red-Green-Refactor Loop with AI in 20 Minutes

Learn test-driven development through the classic three-step cycle, now supercharged with AI pair programming for faster iteration.

Problem: You Write Code Before Understanding Requirements

You dive into implementation, realize your function needs to handle edge cases you didn't think of, and spend hours debugging code that passed "manual testing" but breaks in production.

You'll learn:

  • The three-phase TDD cycle that prevents brittle code
  • How AI assistants accelerate each phase without breaking discipline
  • When TDD actually saves time (and when it doesn't)

Time: 20 min | Level: Intermediate


Why This Matters

Traditional coding writes implementation first, then tests as an afterthought. TDD reverses this: tests define behavior before code exists. This forces you to think through edge cases, interfaces, and failure modes upfront.

Common symptoms of skipping TDD:

  • "It works on my machine" bugs
  • Functions with 8+ parameters because requirements weren't clear
  • Refactoring fear (breaking tests tells you what broke)
  • Debugging sessions that last longer than writing tests would have

The Red-Green-Refactor Loop

Step 1: Red - Write a Failing Test

Start with the smallest possible behavior. The test must fail for the right reason (function doesn't exist yet, not syntax error).

// tests/user-validator.test.ts
import { describe, it, expect } from 'vitest';
import { validateEmail } from '../src/user-validator';

describe('validateEmail', () => {
  it('rejects empty strings', () => {
    // This will fail because validateEmail doesn't exist yet
    expect(validateEmail('')).toBe(false);
  });
});

Run the test:

npm test

Expected output:

⌠validateEmail is not defined

Why this works: You're defining the interface (function name, parameters, return value) before writing code. The failing test proves your test setup is correct.

AI prompt (optional):

"Generate 5 edge case tests for email validation: empty string, null, 
missing @, multiple @, valid email. Use Vitest syntax."

Step 2: Green - Write Minimum Code to Pass

Write the simplest implementation that makes this specific test pass. Resist the urge to handle cases you haven't tested yet.

// src/user-validator.ts
export function validateEmail(email: string): boolean {
  // Hardcoded to pass the one test we wrote
  return email !== '';
}

Run the test:

npm test

Expected output:

âœ" validateEmail › rejects empty strings (2ms)

Why this works: Forces incremental progress. You'll add complexity only when a test demands it.

If it still fails:

  • Test passing but shouldn't: Your test logic is wrong, fix the test
  • Syntax errors: Check import paths match your project structure
  • Wrong assertion: Use toBe() for primitives, toEqual() for objects

Step 3: Refactor - Improve Without Changing Behavior

Now that you have a passing test, clean up code without adding features. Tests prove you didn't break anything.

// src/user-validator.ts
export function validateEmail(email: string): boolean {
  if (!email || email.trim() === '') {
    return false;
  }
  
  // Still minimal, but more robust
  return true;
}

Run tests again:

npm test

Expected: Same passing test, but code is cleaner.

AI prompt for refactoring:

"Refactor this function for readability. Keep the same behavior. 
Suggest variable names and early returns."

Full Cycle Example: Building a Feature

Let's add proper email validation through multiple red-green-refactor cycles.

Cycle 2: Test for Missing @ Symbol

// tests/user-validator.test.ts (add to existing describe block)
it('rejects emails without @ symbol', () => {
  expect(validateEmail('notanemail')).toBe(false);
});

Run test - it passes! Wait, that's wrong. Our code returns true for any non-empty string.

// src/user-validator.ts
export function validateEmail(email: string): boolean {
  if (!email || email.trim() === '') {
    return false;
  }
  
  // Add just enough to make test fail correctly
  return email.includes('@');
}

Now test should pass. Refactor not needed yet.


Cycle 3: Test for Valid Format

it('accepts valid email format', () => {
  expect(validateEmail('user@example.com')).toBe(true);
});

This passes already because of our includes('@') check. Good - move to next case.

it('rejects multiple @ symbols', () => {
  expect(validateEmail('user@@example.com')).toBe(false);
});

Make it pass:

export function validateEmail(email: string): boolean {
  if (!email || email.trim() === '') {
    return false;
  }
  
  const atCount = email.split('@').length - 1;
  if (atCount !== 1) {
    return false;
  }
  
  return true;
}

Refactor time - this is getting messy:

export function validateEmail(email: string): boolean {
  if (!email?.trim()) return false;
  
  const parts = email.split('@');
  if (parts.length !== 2) return false;
  
  const [localPart, domain] = parts;
  return localPart.length > 0 && domain.length > 0;
}

Using AI in Each Phase

Red Phase: Generate Test Cases

Prompt: "List 10 edge cases for password validation: 
min 8 chars, 1 uppercase, 1 number, 1 special char"

AI gives you comprehensive test scenarios you'd forget.

Green Phase: Stub Implementations

Prompt: "Write minimal function to pass this test: 
expect(parseJSON('{invalid}')).toThrow()"

AI writes boilerplate so you focus on logic.

Refactor Phase: Code Review

Prompt: "Refactor for performance. Current: O(n²), all tests must pass"

AI suggests optimizations you can verify with tests.

Warning: Always understand AI-generated code. Copy-paste without reading defeats TDD's purpose.


Verification

Run your full test suite with coverage:

npm test -- --coverage

You should see:

Test Suites: 1 passed
Tests:       5 passed
Coverage:    100% statements, 100% branches

Coverage below 80%? Add tests for uncovered branches. TDD should naturally hit 90%+ coverage.


What You Learned

  • Red: Write smallest failing test first (defines behavior)
  • Green: Write minimum code to pass (no premature optimization)
  • Refactor: Clean code with tests as safety net (no fear of breaking things)
  • AI Role: Speeds up each phase but doesn't replace thinking

When NOT to use TDD:

  • Exploratory prototypes (you're learning, not building)
  • UI layout (visual testing is different)
  • Scripts you'll run once (tests cost more than they save)

Limitations:

  • Initial slowdown (first hour feels slower, saves days later)
  • Requires discipline (tempting to skip tests when "just one quick fix")
  • Integration tests still needed (unit tests don't catch system-level bugs)

Real-World TDD Workflow

// 1. RED: Write test for new feature
it('calculates tax for US customers', () => {
  const order = { subtotal: 100, country: 'US', state: 'CA' };
  expect(calculateTotal(order)).toBe(108.75); // 8.75% CA tax
});

// 2. GREEN: Implement just enough
function calculateTotal(order: Order): number {
  if (order.country === 'US' && order.state === 'CA') {
    return order.subtotal * 1.0875;
  }
  return order.subtotal;
}

// 3. REFACTOR: Extract tax logic
function getTaxRate(country: string, state?: string): number {
  if (country === 'US' && state === 'CA') return 0.0875;
  return 0;
}

function calculateTotal(order: Order): number {
  const taxRate = getTaxRate(order.country, order.state);
  return order.subtotal * (1 + taxRate);
}

// 4. Repeat: Add test for NY tax rate, etc.

Common Pitfalls

Testing Implementation, Not Behavior

// ⌠Bad: Tests internal details
it('calls database.query once', () => {
  const spy = vi.spyOn(database, 'query');
  getUser(1);
  expect(spy).toHaveBeenCalledOnce();
});

// ✅ Good: Tests outcome
it('returns user data for valid ID', () => {
  const user = getUser(1);
  expect(user).toEqual({ id: 1, name: 'Alice' });
});

Writing Tests After Code

// ⌠This isn't TDD
function complexLogic() { /* 200 lines */ }
// ...then write tests

// ✅ This is TDD
it('handles case 1', () => { /* test */ });
function complexLogic() { /* 10 lines for case 1 */ }

it('handles case 2', () => { /* test */ });
// Add 10 more lines to complexLogic

Next Steps

Practice project: Build a shopping cart with TDD

  1. Red: it('starts with zero items')
  2. Green: cart.count() returns 0
  3. Refactor: Extract Cart class
  4. Repeat for add/remove/total

Key takeaway: TDD isn't about testing—it's about design. Tests are the byproduct of thinking through your code's behavior before writing it. AI makes each cycle faster, but the cycle itself is what produces better software.


Tested with Vitest 2.0, TypeScript 5.5, Node.js 22.x