Test-Driven Development (TDD)
Iron Law: "NO PRODUCTION CODE WITHOUT A FAILING TEST FIRST"
When to Use
Use this skill when:
- Writing new functions, methods, or classes
- Adding features to existing code
- Fixing bugs (write test that reproduces bug first)
- Refactoring code (tests verify behavior preservation)
- Implementing API endpoints or business logic
- Creating UI components with testable behavior
Red Flags (Violation Indicators)
Watch for these patterns that indicate TDD violations:
- Implementation First - Writing production code before any test exists ("I'll write the function, then add tests")
- "Tests After" Promise - Planning to write tests later ("Let me implement this quickly, I'll add tests after")
- "Same Purpose" Rationalization - Claiming manual testing is equivalent ("I tested it manually, that's the same thing")
- Skipping "Simple" Code - Avoiding tests for "obvious" logic ("This function is too simple to test")
- Happy Path Only - Writing tests only for success cases, ignoring errors ("The normal case works, that's enough")
- No Test for Changes - Modifying code without adding corresponding test ("Just a small change, doesn't need a test")
- "Just a Small Fix" - Bypassing TDD for "quick fixes" ("It's only one line, I don't need a test")
Violation Detection: If you find yourself saying "I'll test this after I get it working," you're violating TDD.
RED-GREEN-REFACTOR Cycle
TDD follows a strict 3-phase workflow:
Phase 1: RED (Write a Failing Test)
Objective: Specify desired behavior through a test that fails
Steps:
- Write the test first - Before any production code exists
- Define expected behavior - What should the code do?
- Use API you wish existed - Test the interface you want
- Run the test - Verify it fails (if it passes, you didn't test new behavior)
- Check failure reason - Ensure it fails for the right reason (missing code, not syntax error)
Example (TypeScript/Jest):
typescript// tests/user-validator.test.ts describe('UserValidator', () => { it('rejects email without @ symbol', () => { const validator = new UserValidator(); const result = validator.validateEmail('invalid-email'); expect(result.isValid).toBe(false); expect(result.error).toBe('Email must contain @ symbol'); }); }); // Run: npm test // Result: FAIL - UserValidator is not defined ✓ (correct failure)
Red Phase Complete When: Test fails with expected error message
Phase 2: GREEN (Make the Test Pass)
Objective: Write minimal code to make test pass
Steps:
- Write minimal code - Just enough to pass the test
- Don't optimize yet - Resist the urge to add "nice-to-have" features
- Run the test - Verify it passes
- Commit frequently - Small, passing test = commit point
Example (TypeScript):
typescript// src/user-validator.ts interface ValidationResult { isValid: boolean; error?: string; } export class UserValidator { validateEmail(email: string): ValidationResult { if (!email.includes('@')) { return { isValid: false, error: 'Email must contain @ symbol' }; } return { isValid: true }; } } // Run: npm test // Result: PASS ✓
Green Phase Complete When: Test passes consistently
Phase 3: REFACTOR (Improve Design)
Objective: Improve code quality while keeping tests green
Steps:
- Look for duplication - Extract repeated code
- Improve naming - Make intent clearer
- Simplify logic - Reduce complexity
- Run tests after each change - Ensure behavior preserved
- Commit when satisfied - Refactored code + passing tests = commit
Example (TypeScript - Refactored):
typescript// src/user-validator.ts interface ValidationResult { isValid: boolean; error?: string; } export class UserValidator { private static readonly EMAIL_REQUIRED_CHARS = '@'; private static readonly EMAIL_ERROR = 'Email must contain @ symbol'; validateEmail(email: string): ValidationResult { if (!this.containsRequiredChars(email)) { return this.createError(UserValidator.EMAIL_ERROR); } return this.createSuccess(); } private containsRequiredChars(email: string): boolean { return email.includes(UserValidator.EMAIL_REQUIRED_CHARS); } private createError(message: string): ValidationResult { return { isValid: false, error: message }; } private createSuccess(): ValidationResult { return { isValid: true }; } } // Run: npm test // Result: PASS ✓ (behavior unchanged)
Refactor Phase Complete When: Code is clean AND tests still pass
Anti-patterns Table
| Anti-pattern | ✗ Wrong Approach | ✓ Correct TDD Approach |
|---|---|---|
| Test-After | Write calculateTotal() function, then write tests | Write test for calculateTotal(), see it fail, implement function |
| Empty Tests | Write test that always passes: expect(true).toBe(true) | Write test that fails until production code is correct |
| Happy-Path-Only | Test only valid inputs: validateEmail('user@example.com') | Test invalid inputs too: validateEmail('no-at-sign'), validateEmail('') |
| Skip-Simple | Skip test for "obvious" add(a, b) { return a + b } | Write test: expect(add(2, 3)).toBe(5) - bugs hide in "simple" code |
| Change-Then-Test | Modify calculateDiscount(), run app manually, then add test | Write failing test showing bug, modify code until test passes |
Testing Strategy by Code Type
Pure Functions
Pattern: 1 happy path + 3 edge cases minimum
Example (TypeScript):
typescriptdescribe('calculateDiscount', () => { it('applies 10% discount to $100 purchase', () => { expect(calculateDiscount(100, 0.1)).toBe(90); }); it('returns 0 for negative amounts', () => { expect(calculateDiscount(-50, 0.1)).toBe(0); }); it('returns original amount for 0 discount', () => { expect(calculateDiscount(100, 0)).toBe(100); }); it('throws error for discount > 1', () => { expect(() => calculateDiscount(100, 1.5)).toThrow('Discount must be <= 1'); }); });
Error Handlers
Pattern: 1 test per error type + 1 success case
Example (Python/pytest):
pythondef test_divide_by_zero_raises_error(): with pytest.raises(ZeroDivisionError, match="Cannot divide by zero"): divide(10, 0) def test_divide_non_numeric_raises_error(): with pytest.raises(TypeError, match="Arguments must be numbers"): divide("10", 5) def test_divide_returns_float(): result = divide(10, 3) assert result == pytest.approx(3.333, rel=1e-3)
UI Components
Pattern: 1 render test + 3 interaction tests
Example (TypeScript/React Testing Library):
typescriptdescribe('LoginForm', () => { it('renders email and password inputs', () => { render(<LoginForm />); expect(screen.getByLabelText('Email')).toBeInTheDocument(); expect(screen.getByLabelText('Password')).toBeInTheDocument(); }); it('shows error for invalid email', async () => { render(<LoginForm />); await userEvent.type(screen.getByLabelText('Email'), 'invalid'); await userEvent.click(screen.getByRole('button', { name: 'Login' })); expect(screen.getByText('Invalid email address')).toBeInTheDocument(); }); it('disables submit button while loading', async () => { render(<LoginForm onSubmit={async () => await delay(1000)} />); const submitButton = screen.getByRole('button', { name: 'Login' }); await userEvent.click(submitButton); expect(submitButton).toBeDisabled(); }); it('calls onSubmit with form data', async () => { const onSubmit = jest.fn(); render(<LoginForm onSubmit={onSubmit} />); await userEvent.type(screen.getByLabelText('Email'), 'user@example.com'); await userEvent.type(screen.getByLabelText('Password'), 'password123'); await userEvent.click(screen.getByRole('button', { name: 'Login' })); expect(onSubmit).toHaveBeenCalledWith({ email: 'user@example.com', password: 'password123' }); }); });
API Endpoints
Pattern: 1 success test + 2 error tests
Example (Go/testing):
gofunc TestGetUser(t *testing.T) { t.Run("returns user for valid ID", func(t *testing.T) { req := httptest.NewRequest("GET", "/users/123", nil) w := httptest.NewRecorder() handler := NewUserHandler(mockUserRepo) handler.GetUser(w, req) assert.Equal(t, http.StatusOK, w.Code) assert.Contains(t, w.Body.String(), `"id":"123"`) }) t.Run("returns 404 for non-existent user", func(t *testing.T) { req := httptest.NewRequest("GET", "/users/999", nil) w := httptest.NewRecorder() handler := NewUserHandler(mockUserRepo) handler.GetUser(w, req) assert.Equal(t, http.StatusNotFound, w.Code) }) t.Run("returns 400 for invalid ID format", func(t *testing.T) { req := httptest.NewRequest("GET", "/users/invalid", nil) w := httptest.NewRecorder() handler := NewUserHandler(mockUserRepo) handler.GetUser(w, req) assert.Equal(t, http.StatusBadRequest, w.Code) }) }
Examples
Example 1: Bug Fix with TDD (TypeScript)
Scenario: User reports: "App crashes when searching with empty query"
Step 1 (RED): Write test that reproduces bug
typescript// tests/search.test.ts describe('searchProducts', () => { it('returns empty array for empty query', () => { const result = searchProducts(''); expect(result).toEqual([]); }); }); // Run: npm test // Result: FAIL - TypeError: Cannot read property 'toLowerCase' of undefined
Step 2 (GREEN): Fix the bug
typescript// src/search.ts export function searchProducts(query: string): Product[] { if (!query || query.trim() === '') { return []; } return products.filter(p => p.name.toLowerCase().includes(query.toLowerCase())); } // Run: npm test // Result: PASS ✓
Step 3 (REFACTOR): Add more edge cases
typescriptdescribe('searchProducts', () => { it('returns empty array for empty query', () => { expect(searchProducts('')).toEqual([]); }); it('returns empty array for whitespace-only query', () => { expect(searchProducts(' ')).toEqual([]); }); it('is case-insensitive', () => { expect(searchProducts('LAPTOP')).toHaveLength(3); }); });
Example 2: New Feature with TDD (Python)
Scenario: Add email validation to user registration
Step 1 (RED): Write test for feature that doesn't exist
python# tests/test_user.py def test_user_creation_validates_email(): with pytest.raises(ValidationError, match="Invalid email format"): User.create(email="not-an-email", password="secret123") # Run: pytest # Result: FAIL - ValidationError not raised
Step 2 (GREEN): Implement validation
python# src/user.py import re class ValidationError(Exception): pass class User: @classmethod def create(cls, email: str, password: str): if not re.match(r'^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$', email): raise ValidationError("Invalid email format") return cls(email, password) # Run: pytest # Result: PASS ✓
Step 3 (REFACTOR): Extract validation logic
python# src/validators.py class EmailValidator: PATTERN = r'^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$' @classmethod def validate(cls, email: str) -> bool: return bool(re.match(cls.PATTERN, email)) # src/user.py class User: @classmethod def create(cls, email: str, password: str): if not EmailValidator.validate(email): raise ValidationError("Invalid email format") return cls(email, password) # Run: pytest # Result: PASS ✓ (behavior unchanged)
Example 3: API Development with TDD (Go)
Scenario: Implement POST /api/orders endpoint
Step 1 (RED): Write test for non-existent endpoint
go// handlers/orders_test.go func TestCreateOrder(t *testing.T) { t.Run("creates order with valid data", func(t *testing.T) { payload := `{"user_id": "123", "items": [{"product_id": "456", "quantity": 2}]}` req := httptest.NewRequest("POST", "/api/orders", strings.NewReader(payload)) w := httptest.NewRecorder() handler := NewOrderHandler(mockOrderRepo) handler.CreateOrder(w, req) assert.Equal(t, http.StatusCreated, w.Code) assert.Contains(t, w.Body.String(), `"order_id"`) }) } // Run: go test // Result: FAIL - handler.CreateOrder undefined
Step 2 (GREEN): Implement handler
go// handlers/orders.go func (h *OrderHandler) CreateOrder(w http.ResponseWriter, r *http.Request) { var req CreateOrderRequest if err := json.NewDecoder(r.Body).Decode(&req); err != nil { http.Error(w, "Invalid request", http.StatusBadRequest) return } order, err := h.orderRepo.Create(req.UserID, req.Items) if err != nil { http.Error(w, "Failed to create order", http.StatusInternalServerError) return } w.WriteHeader(http.StatusCreated) json.NewEncoder(w).Encode(map[string]string{"order_id": order.ID}) } // Run: go test // Result: PASS ✓
Step 3 (REFACTOR): Add error handling tests
gofunc TestCreateOrder(t *testing.T) { t.Run("creates order with valid data", func(t *testing.T) { // ... existing test }) t.Run("returns 400 for invalid JSON", func(t *testing.T) { req := httptest.NewRequest("POST", "/api/orders", strings.NewReader("invalid")) w := httptest.NewRecorder() handler.CreateOrder(w, req) assert.Equal(t, http.StatusBadRequest, w.Code) }) t.Run("returns 400 for missing user_id", func(t *testing.T) { payload := `{"items": [{"product_id": "456", "quantity": 2}]}` req := httptest.NewRequest("POST", "/api/orders", strings.NewReader(payload)) w := httptest.NewRecorder() handler.CreateOrder(w, req) assert.Equal(t, http.StatusBadRequest, w.Code) }) }
Integration
This skill integrates with:
- verification-before-completion: Test output is primary evidence for completion claims
- systematic-debugging: When test fails, use debugging workflow to find root cause
- code-review: Tests serve as executable documentation and specification
- refactoring: Tests enable safe refactoring (verify behavior preservation)
Common Objections & Responses
Objection: "TDD is too slow, I can code faster without tests" Response: Writing tests first actually saves time by catching bugs early. Debugging later is far more expensive than preventing bugs upfront.
Objection: "This code is too simple to test" Response: Simple code is fastest to test. If it's truly simple, the test takes 30 seconds. If you can't write a fast test, the code isn't simple.
Objection: "I'll write tests after I figure out the design" Response: Tests ARE the design. Writing tests first forces you to think about API usability before implementation locks you in.
Objection: "I need to see if my approach works before committing to tests" Response: That's what the RED phase is for - write a test describing your desired approach, then implement it. If approach changes, update test first.
Summary
Core Principle: Tests first, code second, refactor third.
Workflow:
- RED: Write failing test
- GREEN: Make test pass
- REFACTOR: Improve design
Benefits:
- Catches bugs before they ship
- Documents expected behavior
- Enables fearless refactoring
- Forces good API design
Remember: If you wrote production code without a failing test first, you violated TDD. Delete the code and start over with a test.

