Test-Driven Development (TDD) Expert
Self-contained TDD expertise for ANY user project.
The TDD Cycle: Red-Green-Refactor
- RED Phase: Write Failing Test
Goal: Define expected behavior through a failing test
import { describe, it, expect } from 'vitest'; import { Calculator } from './Calculator';
describe('Calculator', () => { it('should add two numbers', () => { const calculator = new Calculator(); expect(calculator.add(2, 3)).toBe(5); // WILL FAIL - Calculator doesn't exist }); });
RED Checklist:
-
Test describes ONE specific behavior
-
Test fails for RIGHT reason (not syntax error)
-
Test name is clear
-
Expected behavior obvious
- GREEN Phase: Minimal Implementation
Goal: Simplest code that makes test pass
// Calculator.ts export class Calculator { add(a: number, b: number): number { return a + b; // Minimal implementation } }
GREEN Checklist:
-
Test passes
-
Code is simplest possible
-
No premature optimization
-
No extra features
- REFACTOR Phase: Improve Design
Goal: Improve code quality without changing behavior
// Refactor: Support variable arguments export class Calculator { add(...numbers: number[]): number { return numbers.reduce((sum, n) => sum + n, 0); } }
// Tests still pass!
REFACTOR Checklist:
-
All tests still pass
-
Code is more readable
-
Removed duplication
-
Better design patterns
TDD Benefits
Design Benefits:
-
Forces modular, testable code
-
Reveals design problems early
-
Encourages SOLID principles
-
Promotes simple solutions
Quality Benefits:
-
100% test coverage (by definition)
-
Tests document behavior
-
Regression safety net
-
Faster debugging
Productivity Benefits:
-
Less time debugging
-
Confidence to refactor
-
Faster iterations
-
Clearer requirements
BDD: Behavior-Driven Development
Extension of TDD with natural language tests
Given-When-Then Pattern
describe('Shopping Cart', () => { it('should apply 10% discount when total exceeds $100', () => { // Given: A cart with $120 worth of items const cart = new ShoppingCart(); cart.addItem({ price: 120, quantity: 1 });
// When: Getting the total
const total = cart.getTotal();
// Then: 10% discount applied
expect(total).toBe(108); // $120 - $12 (10%)
}); });
BDD Benefits:
-
Tests readable by non-developers
-
Clear business requirements
-
Better stakeholder communication
-
Executable specifications
TDD Patterns
Pattern 1: Test List
Before coding, list all tests needed:
Calculator Tests:
- add two positive numbers
- add negative numbers
- add zero
- add multiple numbers
- multiply two numbers
- divide two numbers
- divide by zero (error)
Work through list one by one.
Pattern 2: Fake It Till You Make It
Start with hardcoded returns, generalize later:
// Test 1: add(2, 3) = 5 add(a, b) { return 5; } // Hardcoded!
// Test 2: add(5, 7) = 12 add(a, b) { return a + b; } // Generalized
Pattern 3: Triangulation
Use multiple tests to force generalization:
// Test 1 expect(fizzbuzz(3)).toBe('Fizz');
// Test 2 expect(fizzbuzz(5)).toBe('Buzz');
// Test 3 expect(fizzbuzz(15)).toBe('FizzBuzz');
// Forces complete implementation
Pattern 4: Test Data Builders
Create test helpers for complex objects:
class UserBuilder { private user = { name: 'Test', email: 'test@example.com', role: 'user' };
withName(name: string) { this.user.name = name; return this; }
withRole(role: string) { this.user.role = role; return this; }
build() { return this.user; } }
// Usage const admin = new UserBuilder().withRole('admin').build();
Refactoring with Confidence
The TDD Safety Net
Refactoring Types
- Extract Method:
// Before function processOrder(order) { const total = order.items.reduce((sum, item) => sum + item.price, 0); const tax = total * 0.1; return total + tax; }
// After (refactored with test safety) function calculateTotal(items) { return items.reduce((sum, item) => sum + item.price, 0); }
function calculateTax(total) { return total * 0.1; }
function processOrder(order) { const total = calculateTotal(order.items); const tax = calculateTax(total); return total + tax; }
- Remove Duplication:
// Tests force you to see duplication it('should validate email', () => { expect(validateEmail('test@example.com')).toBe(true); expect(validateEmail('invalid')).toBe(false); });
it('should validate phone', () => { expect(validatePhone('+1-555-0100')).toBe(true); expect(validatePhone('invalid')).toBe(false); });
// Extract common validation pattern
Refactoring Workflow
- All tests GREEN? → Continue
- Identify code smell
- Make small refactoring
- Run tests → GREEN? → Continue
- Repeat until satisfied
- Commit
TDD Anti-Patterns
❌ Testing Implementation Details
// BAD: Testing private method it('should call _validateEmail internally', () => { spyOn(service, '_validateEmail'); service.createUser({ email: 'test@example.com' }); expect(service._validateEmail).toHaveBeenCalled(); });
// GOOD: Testing behavior it('should reject invalid email', () => { expect(() => service.createUser({ email: 'invalid' })) .toThrow('Invalid email'); });
❌ Writing Tests After Code
// Wrong order!
- Write implementation
- Write tests
// Correct TDD:
- Write test (RED)
- Write implementation (GREEN)
- Refactor
❌ Large Tests
// BAD: Testing multiple behaviors it('should handle user lifecycle', () => { const user = createUser(); updateUser(user, { name: 'New Name' }); deleteUser(user); // Too much in one test! });
// GOOD: One behavior per test it('should create user', () => { const user = createUser(); expect(user).toBeDefined(); });
it('should update user name', () => { const user = createUser(); updateUser(user, { name: 'New Name' }); expect(user.name).toBe('New Name'); });
❌ Skipping Refactor Phase
// Don't skip refactoring! RED → GREEN → REFACTOR → RED → GREEN → REFACTOR ↑________________↑ Always refactor!
Mock-Driven TDD
When testing with external dependencies
Strategy 1: Dependency Injection
class UserService { constructor(private db: Database) {} // Inject dependency
async getUser(id: string) { return this.db.query('SELECT * FROM users WHERE id = ?', [id]); } }
// Test with mock const mockDb = { query: vi.fn().mockResolvedValue({ id: '123' }) }; const service = new UserService(mockDb);
Strategy 2: Interface-Based Mocking
interface EmailService { send(to: string, subject: string, body: string): Promise<void>; }
class MockEmailService implements EmailService { sent: any[] = [];
async send(to: string, subject: string, body: string) { this.sent.push({ to, subject, body }); } }
// Test with mock const mockEmail = new MockEmailService(); const service = new UserService(mockEmail); await service.registerUser({ email: 'test@example.com' }); expect(mockEmail.sent).toHaveLength(1);
SOLID Principles Through TDD
TDD naturally leads to SOLID design
Single Responsibility (SRP)
Tests reveal when class does too much:
// Many tests for one class? Split it! describe('UserManager', () => { // 20+ tests here → Too many responsibilities });
// Refactor to multiple classes describe('UserCreator', () => { /* 5 tests / }); describe('UserValidator', () => { / 5 tests / }); describe('UserNotifier', () => { / 5 tests */ });
Open/Closed (OCP)
Tests enable extension without modification:
// Testable, extensible design interface PaymentProcessor { process(amount: number): Promise<void>; }
class StripeProcessor implements PaymentProcessor { } class PayPalProcessor implements PaymentProcessor { }
Dependency Inversion (DIP)
TDD requires dependency injection:
// Testable: Depends on abstraction class OrderService { constructor(private payment: PaymentProcessor) {} }
// Easy to test with mocks const mockPayment = new MockPaymentProcessor(); const service = new OrderService(mockPayment);
Quick Reference
TDD Workflow
- Write test (RED) → Fails ✅
- Minimal code (GREEN) → Passes ✅
- Refactor → Still passes ✅
- Repeat
Test Smells
-
Test too long (>20 lines)
-
Multiple assertions (>3)
-
Testing implementation
-
Unclear test name
-
Slow tests (>100ms)
-
Flaky tests
When to Use TDD
✅ New features ✅ Bug fixes (add test first) ✅ Refactoring ✅ Complex logic ✅ Public APIs
❌ Throwaway prototypes ❌ UI layout (use E2E instead) ❌ Highly experimental code
This skill is self-contained and works in ANY user project.