
Test Driven Development
- 277 installs
- 63 repo stars
- Updated July 18, 2026
- bobmatnyc/claude-mpm-skills
Practice red-green-refactor TDD while implementing features or fixes so tests define behavior first and regressions are caught before merge or release.
About
The test-driven-development skill instructs Claude to follow strict TDD: write a failing test, implement minimal code to pass, then refactor—keeping features and bug fixes anchored in executable specifications and durable regression coverage.
- Red-green-refactor loop
- Tests before implementation
- Regression prevention
- Refactor-safe coverage
- Behavior-driven test design
Test Driven Development by the numbers
- 277 all-time installs (skills.sh)
- Ranked #730 of 2,153 Testing & QA skills by installs in the Skillselion catalog
- Data as of Aug 1, 2026 (Skillselion catalog sync)
npx skills add https://github.com/bobmatnyc/claude-mpm-skills --skill test-driven-developmentAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 277 |
|---|---|
| repo stars | ★ 63 |
| Last updated | July 18, 2026 |
| Repository | bobmatnyc/claude-mpm-skills ↗ |
What it does
Practice red-green-refactor TDD while implementing features or fixes so tests define behavior first and regressions are caught before merge or release.
Files
Test-Driven Development (TDD)
Comprehensive TDD patterns and practices for all programming languages. This skill eliminates ~500-800 lines of redundant testing guidance per agent.
When to Use
Apply TDD for:
- New feature implementation
- Bug fixes (test the bug first)
- Code refactoring (tests ensure behavior preservation)
- API development (test contracts)
- Complex business logic
TDD Workflow (Red-Green-Refactor)
1. Red Phase: Write Failing Test
Write a test that:
- Describes the desired behavior
- Fails for the right reason (not due to syntax errors)
- Is focused on a single behavior2. Green Phase: Make It Pass
Write the minimum code to:
- Pass the test
- Not introduce regressions
- Follow existing patterns3. Refactor Phase: Improve Code
While keeping tests green:
- Remove duplication
- Improve naming
- Simplify logic
- Extract functions/classesTest Structure Patterns
Arrange-Act-Assert (AAA)
// Arrange: Set up test data and conditions
const user = createTestUser({ role: 'admin' });
// Act: Perform the action being tested
const result = await authenticateUser(user);
// Assert: Verify the outcome
expect(result.isAuthenticated).toBe(true);
expect(result.permissions).toContain('admin');Given-When-Then (BDD Style)
Given: A user with admin privileges
When: They attempt to access protected resource
Then: Access is granted with appropriate permissionsTest Naming Conventions
Pattern: test_should_<expected_behavior>_when_<condition>
Examples:
test_should_return_user_when_id_exists()test_should_raise_error_when_user_not_found()test_should_validate_email_format_when_creating_account()
Language-Specific Conventions
Python (pytest):
def test_should_calculate_total_when_items_added():
# Arrange
cart = ShoppingCart()
cart.add_item(Item("Book", 10.00))
cart.add_item(Item("Pen", 1.50))
# Act
total = cart.calculate_total()
# Assert
assert total == 11.50JavaScript (Jest):
describe('ShoppingCart', () => {
test('should calculate total when items added', () => {
const cart = new ShoppingCart();
cart.addItem({ name: 'Book', price: 10.00 });
cart.addItem({ name: 'Pen', price: 1.50 });
const total = cart.calculateTotal();
expect(total).toBe(11.50);
});
});Go:
func TestShouldCalculateTotalWhenItemsAdded(t *testing.T) {
// Arrange
cart := NewShoppingCart()
cart.AddItem(Item{Name: "Book", Price: 10.00})
cart.AddItem(Item{Name: "Pen", Price: 1.50})
// Act
total := cart.CalculateTotal()
// Assert
if total != 11.50 {
t.Errorf("Expected 11.50, got %f", total)
}
}Test Types and Scope
Unit Tests
- Scope: Single function/method
- Dependencies: Mocked
- Speed: Fast (< 10ms per test)
- Coverage: 80%+ of code paths
Integration Tests
- Scope: Multiple components
- Dependencies: Real or test doubles
- Speed: Moderate (< 1s per test)
- Coverage: Critical paths and interfaces
End-to-End Tests
- Scope: Full user workflows
- Dependencies: Real (in test environment)
- Speed: Slow (seconds to minutes)
- Coverage: Core user journeys
Mocking and Test Doubles
When to Mock
- External APIs and services
- Database operations (for unit tests)
- File system operations
- Time-dependent operations
- Random number generation
Mock Types
Stub: Returns predefined data
def get_user_stub(user_id):
return User(id=user_id, name="Test User")Mock: Verifies interactions
mock_service = Mock()
service.process_payment(payment_data)
mock_service.process_payment.assert_called_once_with(payment_data)Fake: Working implementation (simplified)
class FakeDatabase:
def __init__(self):
self.data = {}
def save(self, key, value):
self.data[key] = value
def get(self, key):
return self.data.get(key)Test Coverage Guidelines
Target Coverage Levels
- Critical paths: 100%
- Business logic: 95%+
- Overall project: 80%+
- UI components: 70%+
What to Test
- ✅ Business logic and algorithms
- ✅ Edge cases and boundary conditions
- ✅ Error handling and validation
- ✅ State transitions
- ✅ Public APIs and interfaces
What NOT to Test
- ❌ Framework internals
- ❌ Third-party libraries
- ❌ Trivial getters/setters
- ❌ Generated code
- ❌ Configuration files
Testing Best Practices
1. One Assertion Per Test (When Possible)
# Good: Focused test
def test_should_validate_email_format():
assert is_valid_email("user@example.com") is True
# Avoid: Multiple unrelated assertions
def test_validation():
assert is_valid_email("user@example.com") is True
assert is_valid_phone("123-456-7890") is True # Different concept2. Test Independence
# Good: Each test is self-contained
def test_user_creation():
user = create_user("test@example.com")
assert user.email == "test@example.com"
# Avoid: Tests depending on execution order
shared_user = None
def test_create_user():
global shared_user
shared_user = create_user("test@example.com")
def test_update_user(): # Depends on previous test
shared_user.name = "Updated"3. Descriptive Test Failures
# Good: Clear failure message
assert result.status == 200, f"Expected 200, got {result.status}: {result.body}"
# Avoid: Unclear failure
assert result.status == 2004. Test Data Builders
# Good: Reusable test data creation
def create_test_user(**overrides):
defaults = {
'email': 'test@example.com',
'name': 'Test User',
'role': 'user'
}
return User(**{**defaults, **overrides})
# Usage
admin = create_test_user(role='admin')
guest = create_test_user(email='guest@example.com')Testing Anti-Patterns to Avoid
❌ Testing Implementation Details
# Bad: Tests internal structure
def test_user_storage():
user = User("test@example.com")
assert user._internal_cache is not None # Implementation detail❌ Fragile Tests
# Bad: Breaks with harmless changes
assert user.to_json() == '{"name":"John","email":"john@example.com"}'
# Good: Tests behavior, not format
data = json.loads(user.to_json())
assert data['name'] == "John"
assert data['email'] == "john@example.com"❌ Slow Tests in Unit Test Suite
# Bad: Real HTTP calls in unit tests
def test_api_integration():
response = requests.get("https://api.example.com/users") # Slow!
assert response.status_code == 200❌ Testing Everything Through UI
# Bad: Testing business logic through UI
def test_calculation():
browser.click("#input1")
browser.type("5")
browser.click("#input2")
browser.type("3")
browser.click("#calculate")
assert browser.find("#result").text == "8"
# Good: Test logic directly
def test_calculation():
assert calculate(5, 3) == 8Quick Reference by Language
Python (pytest)
# Setup/Teardown
@pytest.fixture
def database():
db = create_test_database()
yield db
db.cleanup()
# Parametrized tests
@pytest.mark.parametrize("input,expected", [
("user@example.com", True),
("invalid-email", False),
])
def test_email_validation(input, expected):
assert is_valid_email(input) == expectedJavaScript (Jest)
// Setup/Teardown
beforeEach(() => {
database = createTestDatabase();
});
afterEach(() => {
database.cleanup();
});
// Async tests
test('should fetch user data', async () => {
const user = await fetchUser(1);
expect(user.name).toBe('John');
});Go
// Table-driven tests
func TestEmailValidation(t *testing.T) {
tests := []struct {
input string
expected bool
}{
{"user@example.com", true},
{"invalid-email", false},
}
for _, tt := range tests {
result := IsValidEmail(tt.input)
if result != tt.expected {
t.Errorf("IsValidEmail(%s) = %v, want %v",
tt.input, result, tt.expected)
}
}
}TDD Benefits Realized
- Design Improvement: Tests drive better API design
- Documentation: Tests serve as executable documentation
- Confidence: Refactoring becomes safe
- Debugging: Tests isolate issues quickly
- Coverage: Ensures comprehensive test coverage
- Regression Prevention: Catches bugs before deployment
Related Skills
When using Test Driven Development, these skills enhance your workflow:
- systematic-debugging: Debug-first methodology when tests fail unexpectedly
- react: Testing React components, hooks, and context
- django: Testing Django models, views, and forms
- fastapi-local-dev: Testing FastAPI endpoints and dependency injection
[Full documentation available in these skills if deployed in your bundle]
{
"name": "test-driven-development",
"version": "1.0.0",
"category": "universal",
"toolchain": null,
"framework": null,
"tags": [
"performance",
"async",
"api",
"testing",
"debugging"
],
"entry_point_tokens": 56,
"full_tokens": 18625,
"author": "bobmatnyc",
"license": "MIT",
"requires": [],
"updated": "2025-11-21",
"source_path": "test-driven-development.md",
"source": "https://github.com/bobmatnyc/claude-mpm",
"created": "2025-11-21",
"modified": "2025-11-21",
"maintainer": "Claude MPM Team",
"attribution_required": true,
"repository": "https://github.com/bobmatnyc/claude-mpm-skills"
}
TDD Anti-Patterns
Part of: Test-Driven Development
Category: testing
Reading Level: Intermediate
Purpose
Common TDD mistakes, rationalizations, and red flags to avoid. Learn to recognize when you're violating test-driven development principles.
Red Flags - Recognize and Stop
"I'll Write Tests After to Verify It Works"
What It Sounds Like:
- "Let me confirm this works first, then I'll test"
- "I'll add tests once the feature is done"
- "Tests are part of cleanup, I'll do them later"
Why It's Wrong:
- Tests passing immediately prove nothing
- Didn't watch test fail = don't know if test works
- Implementation may be hard to test (too late to redesign)
- "Later" often becomes "never"
Reality Check:
// Test-after: Passes immediately
test('validates email', () => {
expect(validateEmail('test@example.com')).toBe(true);
});
// ✓ PASS
// But you never saw it fail!
// Does it actually test anything? Unknown.What to Do Instead:
// Test-first: Watch it fail
test('validates email', () => {
expect(validateEmail('test@example.com')).toBe(true);
});
// ✗ FAIL: validateEmail is not defined
// Implement
function validateEmail(email: string): boolean {
return email.includes('@');
}
// ✓ PASS: Now you know test works"Keep as Reference, Write Tests First"
What It Sounds Like:
- "I'll keep this code as a reference while writing tests"
- "I'll adapt this implementation while test-driving"
- "Let me look at what I built to write better tests"
Why It's Wrong:
- You WILL adapt existing code (human nature)
- "Adapt" = testing after, not testing first
- Reference biases your test design
- Defeats purpose of TDD
Reality Check:
// You wrote this "reference":
function processUser(user: any) {
const valid = user.email && user.age >= 18 && user.name;
if (!valid) throw new Error('Invalid');
return { id: generateId(), ...user };
}
// Now you write "test-first" while looking at it:
test('processes valid user', () => {
const user = { email: 'test@example.com', age: 25, name: 'Test' };
const result = processUser(user);
expect(result.id).toBeDefined();
});
// You just tested what you built, not what it should doWhat to Do Instead:
Delete the code. Seriously. Delete it.
Then write test describing what SHOULD happen:
test('creates user with required fields', () => {
const user = createUser({
email: 'test@example.com',
age: 25,
name: 'Test User'
});
expect(user).toMatchObject({
id: expect.any(String),
email: 'test@example.com',
age: 25,
name: 'Test User'
});
});
Now implement fresh from test."Already Spent X Hours, Deleting Is Wasteful"
What It Sounds Like:
- "I can't throw away 4 hours of work"
- "Let me just add tests to verify what I built"
- "Rewriting would take too long"
Why It's Wrong:
- Sunk cost fallacy
- Time already gone, can't recover it
- Keeping unverified code = technical debt
- Will spend more time debugging
Reality Check:
Time spent: 4 hours
Code quality: Unknown
Test coverage: None
Technical debt: High
Option A: Keep it and test after
Time: 4 hours (sunk) + 30 min (weak tests) + 120 min (future debugging)
Total: 6.5 hours
Quality: Low
Option B: Delete and TDD
Time: 4 hours (sunk) + 2 hours (TDD rewrite)
Total: 6 hours
Quality: High
Option B is objectively better.What to Do Instead:
- Accept sunk cost
- Treat it as learning time
- Delete code
- Rewrite with TDD (will be faster, you know the problem now)
"Tests After Achieve the Same Purpose"
What It Sounds Like:
- "I'll have 100% coverage either way"
- "Tests-after verify correctness just as well"
- "It's about the spirit, not the ritual"
- "Being pragmatic means adapting"
Why It's Wrong:
- Coverage ≠ quality
- Tests-after verify what you remembered
- Can't achieve TDD spirit without TDD practice
- Tests-after is optimistic gambling, not pragmatic
Reality Check:
// Test-after: 100% coverage, useless
function add(a: number, b: number): number {
return a - b; // BUG
}
test('add function', () => {
add(2, 3); // 100% coverage!
// But doesn't verify result
});
// Test-first: Forces verification
test('adds two numbers', () => {
expect(add(2, 3)).toBe(5); // Must verify behavior
});See Philosophy for detailed explanation.
"This Is Different Because..."
What It Sounds Like:
- "This is too simple for TDD"
- "This is too complex for TDD"
- "This is exploratory code"
- "This is just a quick bug fix"
- "This is legacy code (can't test)"
Why It's Wrong:
- Every situation has an excuse
- All excuses are invalid
- "Just this once" becomes "always"
Reality Check:
| Excuse | Truth |
|---|---|
| "Too simple" | Simple code breaks too |
| "Too complex" | Complex code NEEDS tests |
| "Exploratory" | Exploration teaches what to test |
| "Quick fix" | Quick fixes need regression protection |
| "Legacy code" | Characterization tests enable refactoring |
What to Do Instead:
- Stop rationalizing
- Follow TDD process
- Every. Single. Time.
Common Anti-Patterns
Pattern: Testing Implementation, Not Behavior
Bad:
test('uses regex to validate email', () => {
const validator = new EmailValidator();
expect(validator.pattern).toBe(/^[^\s@]+@[^\s@]+\.[^\s@]+$/);
});Why Wrong: Tests HOW, not WHAT. Prevents refactoring.
Good:
test('accepts valid email format', () => {
expect(validateEmail('user@example.com')).toBe(true);
});
test('rejects email without @ symbol', () => {
expect(validateEmail('userexample.com')).toBe(false);
});Why Right: Tests behavior. Implementation can change freely.
Pattern: Mocking Everything
Bad:
test('processes user data', () => {
const mockValidator = jest.fn().mockReturnValue(true);
const mockRepository = jest.fn().mockResolvedValue({ id: 1 });
const mockLogger = jest.fn();
const service = new UserService(mockValidator, mockRepository, mockLogger);
service.createUser({ name: 'Test' });
expect(mockValidator).toHaveBeenCalled();
expect(mockRepository).toHaveBeenCalled();
expect(mockLogger).toHaveBeenCalled();
});Why Wrong:
- Tests mock interactions, not real code
- Doesn't verify actual behavior
- Brittle (breaks on implementation changes)
Good:
test('creates user with valid data', async () => {
const service = new UserService();
const user = await service.createUser({
name: 'Test User',
email: 'test@example.com'
});
expect(user.id).toBeDefined();
expect(user.name).toBe('Test User');
expect(user.email).toBe('test@example.com');
});Why Right: Tests real behavior using real objects.
When to Mock: Only external dependencies you don't control (APIs, databases, file system).
Pattern: One Giant Test
Bad:
test('user registration flow', async () => {
// Test validates email
expect(validateEmail('test@example.com')).toBe(true);
// Test validates password
expect(validatePassword('Pass123')).toBe(true);
// Test creates user
const user = await createUser({...});
expect(user.id).toBeDefined();
// Test sends welcome email
expect(emailsSent).toContain('welcome');
// Test logs activity
expect(logs).toContain('user_created');
});Why Wrong:
- Tests multiple behaviors
- If fails, unclear what broke
- Hard to understand what's being tested
- Violates "one test, one behavior"
Good:
test('validates email format', () => {
expect(validateEmail('test@example.com')).toBe(true);
});
test('validates password strength', () => {
expect(validatePassword('Pass123')).toBe(true);
});
test('creates user with valid data', async () => {
const user = await createUser({...});
expect(user.id).toBeDefined();
});
test('sends welcome email after registration', async () => {
await createUser({...});
expect(emailsSent).toContain('welcome');
});Why Right: Each test has single, clear purpose.
Pattern: Vague Assertions
Bad:
test('processes array', () => {
const result = processArray([1, 2, 3]);
expect(result).toBeDefined();
expect(result.length).toBeGreaterThan(0);
});Why Wrong:
- Doesn't verify actual behavior
- Many wrong implementations would pass
- Provides false confidence
Good:
test('doubles each element in array', () => {
const result = processArray([1, 2, 3]);
expect(result).toEqual([2, 4, 6]);
});Why Right: Precise assertion. Only correct implementation passes.
Pattern: Skipping Verification Steps
Bad:
// Write test
test('retries on failure', async () => {
const result = await withRetry(operation);
expect(result).toBe('success');
});
// Implement immediately without running test
async function withRetry(fn) {
try {
return await fn();
} catch (e) {
return await fn();
}
}
// Run both tests together
// PASS: Both pass
// You never saw test fail!Why Wrong:
- Don't know if test actually tests anything
- Implementation might have already existed
- Test might be broken
Good:
// Write test
test('retries on failure', async () => {
const result = await withRetry(operation);
expect(result).toBe('success');
});
// MANDATORY: Run test FIRST
// FAIL: withRetry is not defined
// ✓ Good - test works
// Implement
async function withRetry(fn) {
try {
return await fn();
} catch (e) {
return await fn();
}
}
// Run test AGAIN
// PASS
// ✓ Good - implementation worksWhy Right: Verified test fails, verified test passes. Confidence high.
Rationalization Detection
Self-Assessment Questions
Ask yourself:
Before skipping RED phase:
- [ ] Am I making excuses?
- [ ] Do I think "just this once"?
- [ ] Am I under time pressure? (More reason for TDD!)
- [ ] Do I feel the process is "too slow"? (It's faster overall)
After writing implementation first:
- [ ] Did I write code before test?
- [ ] Am I now writing test to verify existing code?
- [ ] Will my test pass immediately?
- [ ] Did I watch the test fail first?
When reviewing tests:
- [ ] Did I watch each test fail before implementing?
- [ ] Does each test verify behavior, not implementation?
- [ ] Could I refactor without breaking tests?
- [ ] Do tests use real code (not excessive mocks)?
If Any Answer Is "No"
STOP. You're not doing TDD.
Options: 1. Delete implementation, start with test 2. Admit you're not doing TDD, accept consequences 3. Ask for help understanding how to TDD this
Recovery from Anti-Patterns
If You Wrote Code First
Option 1: Characterization Tests (if code works)
// Write tests for current behavior
test('current behavior for case A', () => {
expect(existingFunction(inputA)).toBe(currentOutputA);
});
// Then refactor with test protection
// Then delete and rewrite with TDD for new featuresOption 2: Delete and Restart (if code not verified)
// Delete implementation
// Write failing test
// Implement from testIf Tests Are Too Broad
Refactor tests:
// Before: One giant test
test('user registration', () => {
// 50 lines of test code
});
// After: Multiple focused tests
describe('User Registration', () => {
test('validates email format', () => {...});
test('validates password strength', () => {...});
test('creates user record', () => {...});
test('sends welcome email', () => {...});
});If Tests Are Brittle
Refactor to test behavior:
// Before: Tests implementation
test('uses bcrypt with 10 rounds', () => {
expect(hasher.algorithm).toBe('bcrypt');
expect(hasher.rounds).toBe(10);
});
// After: Tests behavior
test('hashes password securely', () => {
const hash1 = hashPassword('password');
const hash2 = hashPassword('password');
expect(hash1).not.toBe('password'); // Actually hashed
expect(hash1).not.toBe(hash2); // Salted
expect(verifyPassword('password', hash1)).toBe(true); // Verifiable
});Summary
Red Flags:
- Code before test
- Test passes immediately
- "I'll test after"
- "Keep as reference"
- "Deleting is wasteful"
- "Tests-after are equivalent"
- "This is different because..."
Anti-Patterns:
- Testing implementation not behavior
- Mocking everything
- One giant test
- Vague assertions
- Skipping verification steps
Recovery:
- Recognize the anti-pattern
- STOP immediately
- Delete or refactor
- Restart with proper TDD
Remember: Proper TDD requires discipline but saves time. Shortcuts feel faster but waste time.
Related References
- Workflow: Correct TDD process
- Examples: Real-world TDD practice
- Philosophy: Why TDD works
- Integration: TDD with other skills
Real-World TDD Examples
Part of: Test-Driven Development
Category: testing
Reading Level: Intermediate
Purpose
Real-world scenarios demonstrating test-driven development in action, with complete RED/GREEN/REFACTOR cycles and step-by-step walkthroughs.
Example 1: Form Validation
Scenario
Build email validation for user registration form.
Iteration 1: Basic Email Validation
RED - Write Failing Test:
describe('Email Validation', () => {
test('accepts valid email format', () => {
const result = validateEmail('user@example.com');
expect(result.valid).toBe(true);
});
});VERIFY RED - Run Test:
$ npm test
FAIL: accepts valid email format
ReferenceError: validateEmail is not defined✓ Fails correctly - function doesn't exist
GREEN - Minimal Implementation:
interface ValidationResult {
valid: boolean;
error?: string;
}
function validateEmail(email: string): ValidationResult {
return { valid: true }; // Simplest thing that passes
}VERIFY GREEN - Run Test:
$ npm test
PASS: accepts valid email format✓ Test passes
Iteration 2: Reject Invalid Format
RED:
test('rejects email without @ symbol', () => {
const result = validateEmail('userexample.com');
expect(result.valid).toBe(false);
expect(result.error).toBe('Invalid email format');
});VERIFY RED:
$ npm test
FAIL: rejects email without @ symbol
Expected valid: false, Received: true✓ Fails correctly
GREEN:
function validateEmail(email: string): ValidationResult {
if (!email.includes('@')) {
return { valid: false, error: 'Invalid email format' };
}
return { valid: true };
}VERIFY GREEN:
$ npm test
PASS: accepts valid email format
PASS: rejects email without @ symbol✓ Both tests pass
Iteration 3: Reject Multiple @ Symbols
RED:
test('rejects email with multiple @ symbols', () => {
const result = validateEmail('user@@example.com');
expect(result.valid).toBe(false);
});GREEN:
function validateEmail(email: string): ValidationResult {
if (!email.includes('@')) {
return { valid: false, error: 'Invalid email format' };
}
if (email.indexOf('@') !== email.lastIndexOf('@')) {
return { valid: false, error: 'Invalid email format' };
}
return { valid: true };
}REFACTOR - Clean Up:
function validateEmail(email: string): ValidationResult {
const hasAtSymbol = email.includes('@');
const hasSingleAtSymbol = email.indexOf('@') === email.lastIndexOf('@');
if (!hasAtSymbol || !hasSingleAtSymbol) {
return { valid: false, error: 'Invalid email format' };
}
return { valid: true };
}Time: 15 minutes, 3 tests, working validation
Example 2: Bug Fix with TDD
Scenario
Bug reported: Empty shopping cart shows "$0" instead of "Cart is empty" message.
Step 1: Reproduce with Test
RED - Failing Test:
describe('Shopping Cart Display', () => {
test('shows empty message when cart has no items', () => {
const cart = new ShoppingCart();
const display = cart.getDisplayMessage();
expect(display).toBe('Cart is empty');
});
});VERIFY RED:
$ npm test
FAIL: shows empty message when cart has no items
Expected: 'Cart is empty'
Received: 'Total: $0'✓ Test reproduces the bug
Step 2: Fix with Minimal Change
GREEN:
class ShoppingCart {
private items: Item[] = [];
getDisplayMessage(): string {
if (this.items.length === 0) {
return 'Cart is empty';
}
return `Total: $${this.getTotal()}`;
}
getTotal(): number {
return this.items.reduce((sum, item) => sum + item.price, 0);
}
}VERIFY GREEN:
$ npm test
PASS: shows empty message when cart has no items
PASS: calculates total correctly (existing test)✓ Bug fixed, no regressions
Step 3: Prevent Regression
Test stays in suite permanently - bug can never return without test failing.
Time: 10 minutes, bug fixed with regression protection
Example 3: API Client Development
Scenario
Build HTTP client with retry logic for failed requests.
Iteration 1: Basic Request
RED:
describe('HTTP Client', () => {
test('makes GET request successfully', async () => {
const client = new HttpClient('https://api.example.com');
const response = await client.get('/users/1');
expect(response.status).toBe(200);
expect(response.data).toBeDefined();
});
});GREEN:
class HttpClient {
constructor(private baseUrl: string) {}
async get(path: string): Promise<Response> {
const res = await fetch(`${this.baseUrl}${path}`);
return {
status: res.status,
data: await res.json()
};
}
}Iteration 2: Handle Network Errors
RED:
test('retries on network error', async () => {
const client = new HttpClient('https://api.example.com');
// Mock fetch to fail once then succeed
let attempts = 0;
global.fetch = jest.fn().mockImplementation(() => {
attempts++;
if (attempts === 1) {
throw new Error('Network error');
}
return Promise.resolve({
status: 200,
json: () => Promise.resolve({ id: 1 })
});
});
const response = await client.get('/users/1');
expect(response.status).toBe(200);
expect(attempts).toBe(2);
});GREEN:
class HttpClient {
constructor(private baseUrl: string) {}
async get(path: string): Promise<Response> {
try {
return await this.makeRequest(path);
} catch (error) {
// Retry once
return await this.makeRequest(path);
}
}
private async makeRequest(path: string): Promise<Response> {
const res = await fetch(`${this.baseUrl}${path}`);
return {
status: res.status,
data: await res.json()
};
}
}Iteration 3: Configurable Retries
RED:
test('retries up to max attempts', async () => {
const client = new HttpClient('https://api.example.com', {
maxRetries: 3
});
let attempts = 0;
global.fetch = jest.fn().mockImplementation(() => {
attempts++;
if (attempts < 3) {
throw new Error('Network error');
}
return Promise.resolve({
status: 200,
json: () => Promise.resolve({ id: 1 })
});
});
const response = await client.get('/users/1');
expect(response.status).toBe(200);
expect(attempts).toBe(3);
});GREEN:
interface HttpClientOptions {
maxRetries?: number;
}
class HttpClient {
private maxRetries: number;
constructor(
private baseUrl: string,
options: HttpClientOptions = {}
) {
this.maxRetries = options.maxRetries ?? 1;
}
async get(path: string): Promise<Response> {
let lastError: Error;
for (let attempt = 0; attempt <= this.maxRetries; attempt++) {
try {
return await this.makeRequest(path);
} catch (error) {
lastError = error as Error;
}
}
throw lastError!;
}
private async makeRequest(path: string): Promise<Response> {
const res = await fetch(`${this.baseUrl}${path}`);
return {
status: res.status,
data: await res.json()
};
}
}REFACTOR:
// Extract retry logic to utility
async function withRetry<T>(
fn: () => Promise<T>,
maxRetries: number
): Promise<T> {
let lastError: Error;
for (let attempt = 0; attempt <= maxRetries; attempt++) {
try {
return await fn();
} catch (error) {
lastError = error as Error;
}
}
throw lastError!;
}
class HttpClient {
constructor(
private baseUrl: string,
private options: HttpClientOptions = {}
) {}
async get(path: string): Promise<Response> {
return withRetry(
() => this.makeRequest(path),
this.options.maxRetries ?? 1
);
}
private async makeRequest(path: string): Promise<Response> {
const res = await fetch(`${this.baseUrl}${path}`);
return {
status: res.status,
data: await res.json()
};
}
}Time: 30 minutes, robust HTTP client with retry logic
Example 4: Refactoring with Test Protection
Scenario
Legacy code needs refactoring - extract business logic from controller.
Step 1: Write Tests for Current Behavior
RED (characterization tests):
describe('User Registration', () => {
test('registers new user with valid data', async () => {
const controller = new UserController();
const result = await controller.register({
email: 'user@example.com',
password: 'SecurePass123',
name: 'Test User'
});
expect(result.success).toBe(true);
expect(result.userId).toBeDefined();
});
test('rejects duplicate email', async () => {
const controller = new UserController();
await controller.register({
email: 'existing@example.com',
password: 'Pass123',
name: 'First User'
});
const result = await controller.register({
email: 'existing@example.com',
password: 'Pass456',
name: 'Second User'
});
expect(result.success).toBe(false);
expect(result.error).toBe('Email already exists');
});
});GREEN - Tests pass on legacy code:
$ npm test
PASS: registers new user with valid data
PASS: rejects duplicate emailStep 2: Refactor with Test Protection
Before (everything in controller):
class UserController {
async register(data: UserData) {
// Validation
if (!data.email.includes('@')) {
return { success: false, error: 'Invalid email' };
}
if (data.password.length < 8) {
return { success: false, error: 'Password too short' };
}
// Check duplicate
const existing = await db.users.findByEmail(data.email);
if (existing) {
return { success: false, error: 'Email already exists' };
}
// Create user
const user = await db.users.create({
email: data.email,
password: hashPassword(data.password),
name: data.name
});
return { success: true, userId: user.id };
}
}After (extracted service):
// Extract service
class UserService {
async registerUser(data: UserData): Promise<User> {
this.validateUserData(data);
await this.checkDuplicateEmail(data.email);
return this.createUser(data);
}
private validateUserData(data: UserData): void {
if (!data.email.includes('@')) {
throw new Error('Invalid email');
}
if (data.password.length < 8) {
throw new Error('Password too short');
}
}
private async checkDuplicateEmail(email: string): Promise<void> {
const existing = await db.users.findByEmail(email);
if (existing) {
throw new Error('Email already exists');
}
}
private async createUser(data: UserData): Promise<User> {
return db.users.create({
email: data.email,
password: hashPassword(data.password),
name: data.name
});
}
}
// Simplified controller
class UserController {
private userService = new UserService();
async register(data: UserData) {
try {
const user = await this.userService.registerUser(data);
return { success: true, userId: user.id };
} catch (error) {
return { success: false, error: error.message };
}
}
}Verify refactoring:
$ npm test
PASS: registers new user with valid data
PASS: rejects duplicate email✓ Behavior unchanged, structure improved
Step 3: Add Tests for New Service
Now test service directly:
describe('UserService', () => {
test('validates email format', async () => {
const service = new UserService();
await expect(
service.registerUser({
email: 'invalid',
password: 'Pass123456',
name: 'Test'
})
).rejects.toThrow('Invalid email');
});
test('validates password length', async () => {
const service = new UserService();
await expect(
service.registerUser({
email: 'test@example.com',
password: 'short',
name: 'Test'
})
).rejects.toThrow('Password too short');
});
});Time: 45 minutes, safe refactoring with test protection
Example 5: Building a Feature from Scratch
Scenario
Implement shopping cart with add/remove/total functionality.
Complete TDD Session
Iteration 1 - Add Item:
// RED
test('adds item to cart', () => {
const cart = new ShoppingCart();
cart.addItem({ id: 1, name: 'Book', price: 10 });
expect(cart.getItemCount()).toBe(1);
});
// GREEN
class ShoppingCart {
private items: Item[] = [];
addItem(item: Item) {
this.items.push(item);
}
getItemCount(): number {
return this.items.length;
}
}Iteration 2 - Calculate Total:
// RED
test('calculates total price', () => {
const cart = new ShoppingCart();
cart.addItem({ id: 1, name: 'Book', price: 10 });
cart.addItem({ id: 2, name: 'Pen', price: 5 });
expect(cart.getTotal()).toBe(15);
});
// GREEN
class ShoppingCart {
private items: Item[] = [];
addItem(item: Item) {
this.items.push(item);
}
getItemCount(): number {
return this.items.length;
}
getTotal(): number {
return this.items.reduce((sum, item) => sum + item.price, 0);
}
}Iteration 3 - Remove Item:
// RED
test('removes item from cart', () => {
const cart = new ShoppingCart();
cart.addItem({ id: 1, name: 'Book', price: 10 });
cart.addItem({ id: 2, name: 'Pen', price: 5 });
cart.removeItem(1);
expect(cart.getItemCount()).toBe(1);
expect(cart.getTotal()).toBe(5);
});
// GREEN
class ShoppingCart {
private items: Item[] = [];
addItem(item: Item) {
this.items.push(item);
}
removeItem(itemId: number) {
this.items = this.items.filter(item => item.id !== itemId);
}
getItemCount(): number {
return this.items.length;
}
getTotal(): number {
return this.items.reduce((sum, item) => sum + item.price, 0);
}
}Iteration 4 - Handle Empty Cart:
// RED
test('returns zero for empty cart', () => {
const cart = new ShoppingCart();
expect(cart.getTotal()).toBe(0);
});
// GREEN - Already passes!Iteration 5 - Quantity Support:
// RED
test('handles item quantity', () => {
const cart = new ShoppingCart();
cart.addItem({ id: 1, name: 'Book', price: 10 }, 3);
expect(cart.getTotal()).toBe(30);
});
// GREEN
interface CartItem extends Item {
quantity: number;
}
class ShoppingCart {
private items: CartItem[] = [];
addItem(item: Item, quantity: number = 1) {
this.items.push({ ...item, quantity });
}
removeItem(itemId: number) {
this.items = this.items.filter(item => item.id !== itemId);
}
getItemCount(): number {
return this.items.reduce((sum, item) => sum + item.quantity, 0);
}
getTotal(): number {
return this.items.reduce(
(sum, item) => sum + (item.price * item.quantity),
0
);
}
}Time: 25 minutes, complete shopping cart with 5 tests
Key Patterns Demonstrated
Incremental Development
- Each test adds one small behavior
- Build complexity gradually
- Each step verified before next
Test Protection
- Refactoring safe with tests
- Regressions caught immediately
- Behavior preserved across changes
Test-First Benefits
- Clear requirements from tests
- No over-engineering
- 100% relevant test coverage
Time Investment
- Small features: 10-25 minutes
- Medium features: 25-45 minutes
- Includes tests, implementation, refactoring
- Compare to: 15 min coding + 60-120 min debugging
Summary
TDD in practice:
- Start with failing test
- Implement minimally
- Refactor safely
- Build incrementally
- Fast feedback loop
- High confidence
Related References
- Workflow: Complete RED/GREEN/REFACTOR process
- Philosophy: Why TDD works
- Anti-patterns: Common mistakes
- Integration: TDD with other skills
TDD Integration with Other Skills
Part of: Test-Driven Development
Category: testing
Reading Level: Intermediate
Purpose
How to integrate test-driven development with other development skills and workflows, including debugging, refactoring, and defensive programming.
TDD + Systematic Debugging
Integration Point: Bug Reproduction
When a bug is found, combine TDD with systematic debugging:
Process:
1. Systematic Debugging Phase 1: Investigate root cause
- Read error messages
- Reproduce consistently
- Gather evidence
- Form hypothesis
2. TDD RED: Write failing test reproducing bug
test('handles empty array without error', () => {
const result = processArray([]);
expect(result).toEqual([]); // Currently fails
});3. Systematic Debugging Phase 4: Verify hypothesis
- Test confirms bug exists
- Test shows exact failing case
4. TDD GREEN: Fix implementation
function processArray(items: Item[]): Result[] {
if (items.length === 0) {
return []; // Fix edge case
}
return items.map(transform);
}5. TDD Verify GREEN: Confirm fix
- Test passes
- Bug resolved
- Regression protection added
Example: Complete Bug Fix Workflow
Bug Report: "Application crashes when user has no items"
Step 1: Systematic Investigation
Error: Cannot read property 'map' of undefined
Stack trace shows: processArray called with undefined
Root cause: items parameter is undefined when user has no itemsStep 2: Write Failing Test
test('handles undefined items gracefully', () => {
const result = processArray(undefined);
expect(result).toEqual([]);
});
// Run test
// FAIL: TypeError: Cannot read property 'map' of undefined
// ✓ Test reproduces bugStep 3: Minimal Fix
function processArray(items?: Item[]): Result[] {
if (!items || items.length === 0) {
return [];
}
return items.map(transform);
}Step 4: Verify Fix
$ npm test
PASS: handles undefined items gracefully
PASS: processes normal array (existing test)Benefit: Bug fixed with permanent regression protection.
TDD + Refactoring
Integration Point: Safe Refactoring
Tests enable confident refactoring by catching breaks immediately.
Workflow:
1. Ensure Tests Exist: Before refactoring, verify comprehensive test coverage 2. Verify GREEN: All tests pass before starting 3. Refactor: Make structural changes 4. Verify GREEN: Tests still pass = behavior preserved 5. Repeat: Continue refactoring with confidence
Example: Extract Service Layer
Before Refactoring:
// All logic in controller
class UserController {
async register(req, res) {
// Validation
if (!req.body.email.includes('@')) {
return res.status(400).json({ error: 'Invalid email' });
}
// Business logic
const user = await db.users.create(req.body);
// Response
return res.json({ user });
}
}
// Tests exist
describe('User Registration', () => {
test('registers valid user', async () => {
const response = await request(app)
.post('/register')
.send({ email: 'test@example.com', name: 'Test' });
expect(response.status).toBe(200);
expect(response.body.user).toBeDefined();
});
test('rejects invalid email', async () => {
const response = await request(app)
.post('/register')
.send({ email: 'invalid', name: 'Test' });
expect(response.status).toBe(400);
});
});Refactoring Process:
1. Verify GREEN: Tests pass ✓
2. Extract validation:
function validateEmail(email: string): boolean {
return email.includes('@');
}
class UserController {
async register(req, res) {
if (!validateEmail(req.body.email)) {
return res.status(400).json({ error: 'Invalid email' });
}
const user = await db.users.create(req.body);
return res.json({ user });
}
}3. Verify GREEN: Tests pass ✓
4. Extract service:
class UserService {
async createUser(data: UserData): Promise<User> {
if (!validateEmail(data.email)) {
throw new Error('Invalid email');
}
return db.users.create(data);
}
}
class UserController {
async register(req, res) {
try {
const user = await this.userService.createUser(req.body);
return res.json({ user });
} catch (error) {
return res.status(400).json({ error: error.message });
}
}
}5. Verify GREEN: Tests pass ✓
Result: Refactored safely, behavior preserved, tests confirm correctness.
TDD + Defense in Depth
Integration Point: Validation at Boundaries
After TDD implementation, add defensive validation layers.
Workflow:
1. TDD Implementation: Build feature with tests 2. Add Input Validation: Test-drive validation at entry points 3. Add Precondition Checks: Test-drive assertions in functions 4. Add Error Handling: Test-drive error cases
Example: Building Robust Payment Processing
Phase 1: Core Functionality (TDD)
test('processes payment successfully', async () => {
const result = await processPayment({
amount: 100,
cardNumber: '4111111111111111',
cvv: '123'
});
expect(result.success).toBe(true);
expect(result.transactionId).toBeDefined();
});
function processPayment(payment: Payment): Promise<PaymentResult> {
return paymentGateway.charge(payment);
}Phase 2: Add Validation (TDD)
test('rejects negative amounts', async () => {
await expect(
processPayment({ amount: -100, cardNumber: '4111', cvv: '123' })
).rejects.toThrow('Amount must be positive');
});
test('validates card number format', async () => {
await expect(
processPayment({ amount: 100, cardNumber: 'invalid', cvv: '123' })
).rejects.toThrow('Invalid card number');
});
function processPayment(payment: Payment): Promise<PaymentResult> {
if (payment.amount <= 0) {
throw new Error('Amount must be positive');
}
if (!isValidCardNumber(payment.cardNumber)) {
throw new Error('Invalid card number');
}
return paymentGateway.charge(payment);
}Phase 3: Add Defensive Checks (TDD)
test('handles gateway timeout', async () => {
paymentGateway.charge = jest.fn().mockRejectedValue(new Error('Timeout'));
const result = await processPayment({
amount: 100,
cardNumber: '4111111111111111',
cvv: '123'
});
expect(result.success).toBe(false);
expect(result.error).toBe('Payment gateway timeout');
});
async function processPayment(payment: Payment): Promise<PaymentResult> {
// Validation
validatePayment(payment);
// Defensive processing
try {
return await paymentGateway.charge(payment);
} catch (error) {
if (error.message.includes('Timeout')) {
return { success: false, error: 'Payment gateway timeout' };
}
throw error;
}
}Result: Robust system built incrementally with TDD, each layer tested.
TDD + Verification Before Completion
Integration Point: Completion Checklist
Before marking work complete, verify TDD was followed.
Checklist:
- [ ] Every new function/method has tests
- [ ] Watched each test fail before implementing
- [ ] Each test failed for expected reason (feature missing, not typo)
- [ ] Wrote minimal code to pass each test
- [ ] All tests pass
- [ ] No warnings in test output
- [ ] Tests use real code (mocks only if unavoidable)
- [ ] Edge cases covered
- [ ] Error cases tested
If can't check all boxes: Return to TDD process.
Example: Code Review with TDD Verification
Pull Request Checklist:
## TDD Verification
- [x] All new code has corresponding tests
- [x] Tests were written before implementation
- [x] Watched tests fail with correct error messages
- [x] Tests pass after implementation
- [x] Edge cases tested: empty input, null values, invalid data
- [x] Error cases tested: network failures, validation errors
- [x] No test warnings or errors in output
- [ ] Considered security implications
Test Coverage: 95%
New Tests: 12
Modified Tests: 3If checklist incomplete: Request changes to follow TDD properly.
TDD + Continuous Integration
Integration Point: Automated Test Runs
CI/CD pipeline runs tests automatically, ensuring TDD benefits persist.
CI Configuration:
# .github/workflows/test.yml
name: Test
on: [push, pull_request]
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- name: Setup Node
uses: actions/setup-node@v3
with:
node-version: '18'
- name: Install dependencies
run: npm ci
- name: Run tests
run: npm test -- --coverage
- name: Check coverage threshold
run: |
COVERAGE=$(npm test -- --coverage --silent | grep "All files" | awk '{print $10}' | tr -d '%')
if [ $COVERAGE -lt 80 ]; then
echo "Coverage $COVERAGE% is below 80%"
exit 1
fi
- name: Fail if warnings
run: npm test -- --maxWarnings=0Benefits:
- Every commit runs tests
- Prevents regression
- Enforces coverage requirements
- Blocks merging if tests fail
TDD + Code Review
Integration Point: Review Process
Code reviews verify TDD practices were followed.
Review Checklist for Reviewers:
Tests Quality:
- [ ] Tests exist for all new code
- [ ] Tests are clear and focused (one behavior each)
- [ ] Tests verify behavior, not implementation
- [ ] Minimal use of mocks
- [ ] Edge cases covered
- [ ] Error cases covered
TDD Evidence:
- [ ] Commit history shows test-then-implementation pattern
- [ ] Tests are simple and clear
- [ ] Implementation is minimal (no over-engineering)
- [ ] Code structure suggests test-driven design
Red Flags:
- [ ] Tests added in separate "add tests" commit
- [ ] Tests appear to verify existing implementation
- [ ] Over-complicated implementation
- [ ] Tests pass immediately (never failed)
Example Review Comments
Good TDD:
✓ Clean test-driven design
✓ Tests clearly show requirements
✓ Implementation is minimal and focused
✓ Excellent TDD practice!Needs Improvement:
⚠ Tests appear to be written after implementation
⚠ Test passes immediately - did you watch it fail?
⚠ Implementation more complex than tests require
⚠ Please follow RED/GREEN/REFACTOR cycleTDD + Pair Programming
Integration Point: Real-time Collaboration
Pair programming enforces TDD discipline through peer accountability.
Ping-Pong Pattern:
1. Developer A: Write failing test 2. Developer B: Make test pass 3. Both: Refactor together 4. Developer B: Write next failing test 5. Developer A: Make test pass 6. Repeat
Benefits:
- Enforces test-first (partner won't let you skip)
- Catches anti-patterns immediately
- Shared understanding of requirements
- Better test design from two perspectives
Summary
TDD Integrations:
1. Systematic Debugging: Write failing test to reproduce bug 2. Refactoring: Tests enable safe structural changes 3. Defense in Depth: Test-drive validation and error handling 4. Verification: Checklist ensures TDD was followed 5. CI/CD: Automated test runs preserve TDD benefits 6. Code Review: Verify TDD practices in review 7. Pair Programming: Enforce TDD through collaboration
Key Principle: TDD integrates with all development practices by providing test-based foundation for confidence.
Related References
- Workflow: Complete RED/GREEN/REFACTOR cycle
- Examples: Real-world TDD scenarios
- Philosophy: Why TDD works
- Anti-patterns: Common mistakes
TDD Philosophy: Why Order Matters
Part of: Test-Driven Development
Category: testing
Reading Level: Advanced
Purpose
Deep dive into why test-first development works and why tests-after fundamentally cannot achieve the same benefits, despite appearing similar.
The Core Question
"Why can't I write tests after? I'll still have 100% coverage."
This question misunderstands what TDD provides. Coverage is the least important benefit.
What Tests-First Actually Provides
1. Design Feedback
Test-First:
// Write test first
test('calculates shipping cost', () => {
const cost = calculateShipping({ weight: 10, distance: 500 });
expect(cost).toBe(25);
});
// Forces you to design a clean API
// - What parameters does it need?
// - What does it return?
// - Is it easy to call?Test-After:
// Write implementation first
function calculateShipping(order: Order) {
const baseRate = config.shipping.baseRate;
const weightFactor = config.shipping.weightFactor;
const distanceFactor = config.shipping.distanceFactor;
const specialHandling = order.items.some(i => i.fragile);
// ... complex logic
return cost;
}
// Write test for what you built
test('calculates shipping cost', () => {
const order = createComplexOrderObject();
const cost = calculateShipping(order);
expect(cost).toBeGreaterThan(0); // Vague assertion
});
// Test accepts complex API because implementation already existsOutcome:
- Test-First: Simple, clean API emerged from test
- Test-After: Complex API accepted because changing implementation is "wasteful"
2. Requirements Verification
Test-First:
// Test defines requirement
test('rejects invalid email format', () => {
const result = validateEmail('invalid');
expect(result.valid).toBe(false);
expect(result.error).toBe('Invalid email format');
});
// Implementation must satisfy exact requirement
// Can't "forget" edge cases - test will failTest-After:
// Implement based on memory
function validateEmail(email: string) {
return email.includes('@'); // Forgot other validations
}
// Write test for what you remembered to implement
test('validates email', () => {
expect(validateEmail('user@example.com')).toBe(true);
expect(validateEmail('invalid')).toBe(false);
// Didn't test: multiple @, domain validation, etc.
});
// Test passes, but incompleteOutcome:
- Test-First: Test drives complete implementation
- Test-After: Test verifies what you remembered
3. Proof of Test Quality
Test-First:
// RED: Write test, watch it fail
test('retries on failure', async () => {
const result = await withRetry(failingOperation);
expect(result).toBe('success');
});
// RUN: See failure
// FAIL: withRetry is not defined
// You KNOW test works because you saw it failTest-After:
// Write implementation
async function withRetry(fn) {
try {
return await fn();
} catch (e) {
return await fn();
}
}
// Write test
test('retries on failure', async () => {
const result = await withRetry(succeedingOperation);
expect(result).toBe('success');
});
// RUN: Test passes immediately
// PASS ✓
// But test is broken! It never fails, so it tests nothing.Outcome:
- Test-First: Watched fail → know it works
- Test-After: Passes immediately → might be broken
Why "Tests-After Achieve Same Goals" Is Wrong
Claim: "Tests-after give same coverage"
Reality: Coverage measures lines executed, not correctness verified.
// 100% coverage, useless test
function add(a: number, b: number): number {
return a - b; // BUG: subtraction instead of addition
}
test('add function', () => {
add(2, 3); // Executes line = 100% coverage
// But doesn't verify result!
});Coverage is not quality.
Claim: "I test all edge cases after implementation"
Reality: You test edge cases you remember. Test-first discovers edge cases.
// Test-first: Edge cases emerge naturally
// RED: Basic case
test('processes single item', () => {
expect(process([item])).toEqual([processed]);
});
// GREEN: Implement
function process(items) {
return items.map(transform);
}
// RED: What about empty?
test('processes empty array', () => {
expect(process([])).toEqual([]);
});
// Forces you to think about edge case
// Test-after: Edge cases you remember
function process(items) {
return items.map(transform);
}
test('processes array', () => {
expect(process([item])).toEqual([processed]);
// Forgot empty array - test never forced you to consider it
});Claim: "30 minutes of tests-after is same as TDD"
Reality: The difference is what happens during those 30 minutes.
Test-First 30 Minutes: 1. Write test defining behavior (5 min) 2. Watch it fail - verify test works (1 min) 3. Implement to pass test (10 min) 4. Watch it pass - verify implementation works (1 min) 5. Refactor safely with tests (8 min) 6. Next feature (5 min)
Result: 5 behaviors implemented, all tested, refactored
Test-After 30 Minutes: 1. Implement all 5 behaviors (20 min) 2. Write tests for what you built (10 min) 3. All tests pass immediately 4. Hope you didn't forget anything
Result: 5 behaviors implemented, tests of unknown quality
Claim: "It's about spirit, not ritual"
Reality: The "spirit" is discovered through the "ritual."
The spirit of TDD is:
- Let tests drive design
- Verify requirements incrementally
- Get immediate feedback
- Build confidence through observation
You cannot achieve this spirit without the ritual:
- Test must fail first (drives design)
- Implementation must be minimal (incremental)
- Test must pass after (immediate feedback)
- You must watch both (builds confidence)
Analogy: "I understand the spirit of weightlifting, so I'll visualize lifting weights instead of actually lifting them."
The spirit emerges from the practice, not from understanding the principles.
The Sunk Cost Fallacy
Situation
You: "I've already written 500 lines of implementation"
Partner: "Write tests first, delete that code"
You: "But I spent 4 hours on it! Deleting is wasteful!"Analysis
Time already spent: 4 hours (GONE, cannot recover)
Option A: Keep it and test after
- Time: 4 hours (sunk) + 30 min (tests)
- Result: Code of unknown quality, weak tests
- Future: Likely 2-4 hours debugging issues
- Total: 6.5-8.5 hours
Option B: Delete and TDD
- Time: 4 hours (sunk) + 2 hours (TDD rewrite)
- Result: Clean code, strong tests
- Future: Minimal debugging
- Total: 6 hours
Option B is objectively better despite feeling worse.
Psychological Trap
The 4 hours feel "wasted" if you delete code.
But:
- Those 4 hours taught you about the problem
- Rewrite with TDD will be faster (you understand it now)
- Quality will be higher
- You avoid future debugging time
The 4 hours weren't wasted - they were learning.
The Pragmatism Argument
Claim: "TDD is dogmatic, pragmatism means adapting to situation"
Reality: TDD IS pragmatic. Tests-after is optimistic gambling.
Pragmatic Question: Which approach has better ROI?
Test-First:
- Time: 30 min (test + implementation)
- Bugs found: Before commit
- Debugging time: ~5 min (test tells you exactly what broke)
- Regression risk: Near zero
- Refactoring confidence: High
- Total time: ~35 min
Test-After:
- Time: 20 min (implementation) + 10 min (tests)
- Bugs found: In production (maybe)
- Debugging time: 60-120 min (investigate what broke)
- Regression risk: Medium
- Refactoring confidence: Low
- Total time: 90-150 min
Which is pragmatic?
Real-World Data
From industry studies and team observations:
TDD Projects:
- 40-80% fewer bugs in production
- 15-35% more development time upfront
- 50-90% less debugging time
- Net: 20-40% less total time
Non-TDD Projects:
- More bugs in production
- Less development time upfront
- Significantly more debugging time
- Net: More total time, lower quality"Pragmatic" shortcuts = long-term waste
The Manual Testing Trap
Claim: "I already manually tested all edge cases"
Problems:
1. Manual testing is unreliable
You test:
- Happy path
- One error case
- Edge case you thought of
You forget:
- Edge cases you didn't think of
- Error cases you didn't encounter
- Combinations of conditions2. Manual testing doesn't scale
Feature A: 5 min manual test
Feature B: 5 min manual test
Feature C: 5 min manual test
After Feature C:
To test everything: 15 min
After Feature Z: 130 min
After refactoring: 130 min AGAIN3. Manual testing has no record
You: "I tested this"
Later: "Did you test X condition?"
You: "I think so? Maybe?"
No proof, must test againAutomated tests:
- Run in seconds
- Test exact same way every time
- Permanent record of what's tested
- Run on every change
The "Just This Once" Trap
Pattern
Situation 1: "This is simple, skip TDD just this once"
Situation 2: "This is urgent, skip TDD just this once"
Situation 3: "This is exploratory, skip TDD just this once"
Situation 4: "This is a bug fix, skip TDD just this once"Result: TDD never happens
Reality Check
Every situation has an excuse:
- Simple → "Not worth testing"
- Complex → "Too hard to test"
- Urgent → "No time to test"
- Exploratory → "Will throw away anyway"
- Bug fix → "Just need quick fix"
All excuses are wrong:
- Simple code breaks
- Complex code NEEDS tests
- Urgent code needs to be correct
- Exploration teaches you what to test
- Bug fixes need regression protection
What Tests-First Actually Feels Like
Common Experience
Week 1-2: Frustrating
- Feels slower
- Fighting the process
- "Why can't I just write the code?"
Week 3-4: Understanding
- Starting to see benefits
- Tests catch bugs before commit
- Less debugging time
Week 5+: Natural
- Can't imagine coding without tests first
- Feels faster than old way
- Confidence in changes
The Shift
Before TDD:
Write code → Run → Debug → Fix → Run → Debug → Fix → Done?
Anxiety: "Did I break anything?"After TDD:
Write test → Watch fail → Write code → Watch pass → Done!
Confidence: "All tests green = definitely works"Summary
Tests-First ≠ Tests-After Because:
1. Design: Test-first drives clean design 2. Requirements: Test-first discovers edge cases 3. Proof: Test-first proves tests work 4. Feedback: Test-first gives immediate feedback 5. Confidence: Test-first builds real confidence
Common Misconceptions:
- ✗ "Coverage is the goal" → Quality is the goal
- ✗ "Tests-after are equivalent" → Fundamentally different
- ✗ "Deleting code is wasteful" → Sunk cost fallacy
- ✗ "TDD is dogmatic" → TDD is pragmatic
- ✗ "Manual testing suffices" → Doesn't scale or persist
- ✗ "Just this once" → Becomes every time
The Truth:
TDD takes discipline but saves time. Tests-after feels faster but wastes time. The only way to understand is to practice TDD properly for 30 days.
Related References
- Workflow: How to practice TDD
- Examples: Real-world scenarios
- Anti-patterns: Common mistakes
- Integration: TDD with other skills
Complete TDD Workflow
Part of: Test-Driven Development
Category: testing
Reading Level: Intermediate
Purpose
Complete step-by-step workflow for the RED/GREEN/REFACTOR cycle, with detailed instructions, examples, and verification criteria for each phase.
The Complete Cycle
┌──────────────────────────────────────────────────────┐
│ TDD CYCLE │
├──────────────────────────────────────────────────────┤
│ │
│ RED: Write Failing Test │
│ ↓ │
│ VERIFY RED: Watch it fail correctly │
│ ↓ │
│ GREEN: Minimal implementation │
│ ↓ │
│ VERIFY GREEN: Watch it pass │
│ ↓ │
│ REFACTOR: Improve code (optional) │
│ ↓ │
│ REPEAT: Next test for next feature │
│ │
└──────────────────────────────────────────────────────┘Phase 1: RED - Write Failing Test
Goal
Create one minimal test that describes desired behavior.
Steps
1. Identify Single Behavior
Ask: What is ONE thing this code should do?
Not: "Handle user authentication and authorization and validation"
But: "Validate email format"2. Write Clear Test Name
// Good: Describes behavior
test('rejects email without @ symbol', () => {})
test('accepts valid email format', () => {})
test('trims whitespace from email', () => {})
// Bad: Vague or implementation-focused
test('test1', () => {})
test('email validation works', () => {})
test('uses regex pattern', () => {})3. Write Test Body
Structure:
test('behavior description', () => {
// Arrange: Set up test data
const input = 'test data';
// Act: Call the function
const result = functionUnderTest(input);
// Assert: Verify behavior
expect(result).toBe(expected);
});Good Example:
test('retries failed operations 3 times', async () => {
let attempts = 0;
const operation = () => {
attempts++;
if (attempts < 3) throw new Error('fail');
return 'success';
};
const result = await retryOperation(operation);
expect(result).toBe('success');
expect(attempts).toBe(3);
});Bad Example:
test('retry works', async () => {
const mock = jest.fn()
.mockRejectedValueOnce(new Error())
.mockRejectedValueOnce(new Error())
.mockResolvedValueOnce('success');
await retryOperation(mock);
expect(mock).toHaveBeenCalledTimes(3);
});
// Problem: Tests mock behavior, not real codeTest Quality Checklist
- [ ] Tests ONE behavior
- [ ] Name clearly describes what should happen
- [ ] Uses real code (mocks only if unavoidable)
- [ ] Arrange/Act/Assert structure clear
- [ ] Easy to understand what's being tested
Phase 2: VERIFY RED - Watch It Fail
Goal
Confirm test fails for the RIGHT reason.
Steps
1. Run Test
# Run specific test file
npm test path/to/test.test.ts
# Or run single test
npm test -- -t "behavior description"2. Verify Failure Type
✓ Good Failure (Feature Missing):
FAIL: retries failed operations 3 times
ReferenceError: retryOperation is not defined
Expected: 'success'
Received: undefined✗ Bad Failure (Test Error):
FAIL: retries failed operations 3 times
SyntaxError: Unexpected token
TypeError: Cannot read property 'x' of undefined✗ Bad Outcome (Test Passes):
PASS: retries failed operations 3 times→ You're testing existing behavior, not new feature
What to Check
| Outcome | Meaning | Action |
|---|---|---|
| Test fails with "not defined" | ✓ Good - feature missing | Proceed to GREEN |
| Test fails with "Expected X, got Y" | ✓ Good - behavior wrong | Proceed to GREEN |
| Test errors (syntax, type) | ✗ Fix test | Fix error, rerun VERIFY RED |
| Test passes | ✗ Testing existing behavior | Rewrite test for NEW behavior |
Common Issues
Issue: Test Passes Immediately
// Problem: Testing existing code
test('processes array', () => {
const result = processArray([1, 2, 3]);
expect(result).toBeDefined(); // Already works!
});
// Fix: Test NEW behavior
test('processes empty array', () => {
const result = processArray([]);
expect(result).toEqual([]); // Currently fails
});Issue: Test Errors Instead of Failing
// Problem: Typo in test
test('validates email', () => {
const result = validateEmial('test@example.com'); // Typo
expect(result).toBe(true);
});
// Fix: Correct typo, rerun
test('validates email', () => {
const result = validateEmail('test@example.com');
expect(result).toBe(true);
});Phase 3: GREEN - Minimal Implementation
Goal
Write simplest code to make test pass.
Steps
1. Write Minimal Code
Good (Minimal):
async function retryOperation<T>(fn: () => Promise<T>): Promise<T> {
for (let i = 0; i < 3; i++) {
try {
return await fn();
} catch (e) {
if (i === 2) throw e;
}
}
throw new Error('unreachable');
}Bad (Over-engineered):
async function retryOperation<T>(
fn: () => Promise<T>,
options?: {
maxRetries?: number;
backoff?: 'linear' | 'exponential';
onRetry?: (attempt: number) => void;
timeout?: number;
}
): Promise<T> {
// YAGNI - You Aren't Gonna Need It
}2. Resist Temptations
DON'T:
- Add features not tested
- Refactor other code
- "Improve" beyond test requirements
- Add configuration options "for flexibility"
- Handle edge cases not in test
DO:
- Write exactly enough to pass test
- Keep it simple
- Save improvements for REFACTOR phase
- Trust that next test will drive next feature
Implementation Patterns
Pattern: Hardcode First
// Test: User can login with valid credentials
test('logs in with valid credentials', async () => {
const result = await login('user@example.com', 'password123');
expect(result.success).toBe(true);
});
// First implementation: Hardcode it
function login(email: string, password: string) {
return { success: true }; // Simplest thing that passes
}
// Next test will force real implementation
test('rejects invalid credentials', async () => {
const result = await login('user@example.com', 'wrongpass');
expect(result.success).toBe(false);
});
// Now implement for real
function login(email: string, password: string) {
const valid = checkCredentials(email, password);
return { success: valid };
}Pattern: Fake It Then Make It
// Iteration 1: Fake it
function calculateTotal(items: Item[]) {
return 100; // Hardcoded to pass first test
}
// Iteration 2: Make it real
function calculateTotal(items: Item[]) {
return items.reduce((sum, item) => sum + item.price, 0);
}Phase 4: VERIFY GREEN - Watch It Pass
Goal
Confirm test passes and nothing broke.
Steps
1. Run Test
npm test path/to/test.test.ts2. Verify Success
✓ Good:
PASS: retries failed operations 3 times✗ Bad (Still Failing):
FAIL: retries failed operations 3 times→ Fix implementation, don't change test
✗ Bad (New Test Passes, Old Tests Fail):
PASS: retries failed operations 3 times
FAIL: handles immediate success→ Fix regression immediately
3. Run Full Test Suite
# Run ALL tests
npm test
# Verify output
All tests passed? → Proceed to REFACTOR
Some tests failed? → Fix regressions before continuingWhat to Check
- [ ] New test passes
- [ ] All existing tests pass
- [ ] No warnings in output
- [ ] No console errors
- [ ] Build succeeds
Common Issues
Issue: Test Still Fails
// Test expects 3 retries
expect(attempts).toBe(3);
// But implementation only does 2
for (let i = 0; i < 2; i++) {
// Fix: Change to 3
}Issue: Other Tests Break
// New code breaks existing functionality
function processArray(arr: number[]) {
return arr.map(x => x * 2); // Breaks test expecting sum
}
// Fix implementation to satisfy both tests
function processArray(arr: number[], operation: 'sum' | 'double') {
if (operation === 'sum') return arr.reduce((a, b) => a + b);
return arr.map(x => x * 2);
}Phase 5: REFACTOR - Improve Code
Goal
Clean up code while keeping tests green.
When to Refactor
Do refactor when:
- Code is duplicated
- Names are unclear
- Logic is complex
- Structure is messy
Don't refactor when:
- Tests are red
- Adding new behavior
- Time pressure (do it next cycle)
Refactoring Steps
1. Identify Improvements
// Before: Duplication
function validateEmail(email: string) {
if (!email.includes('@')) return false;
if (email.indexOf('@') !== email.lastIndexOf('@')) return false;
return true;
}
function validateUsername(username: string) {
if (username.length < 3) return false;
if (username.length > 20) return false;
return true;
}
// After: Extract common pattern
function validateLength(str: string, min: number, max: number) {
return str.length >= min && str.length <= max;
}
function validateEmail(email: string) {
return email.includes('@') &&
email.indexOf('@') === email.lastIndexOf('@');
}
function validateUsername(username: string) {
return validateLength(username, 3, 20);
}2. Refactor Incrementally
Make ONE change → Run tests → Green? → Next changeDON'T: Make multiple changes then run tests DO: Change → Test → Change → Test
3. Keep Tests Green
If tests turn red during refactoring:
- STOP
- Revert change
- Try smaller change
- Keep tests green at all times
Common Refactorings
Extract Method:
// Before
function processUser(user: User) {
const valid = user.email.includes('@') &&
user.age >= 18 &&
user.name.length > 0;
if (!valid) throw new Error('Invalid');
// ...
}
// After
function validateUser(user: User): boolean {
return user.email.includes('@') &&
user.age >= 18 &&
user.name.length > 0;
}
function processUser(user: User) {
if (!validateUser(user)) throw new Error('Invalid');
// ...
}Rename for Clarity:
// Before
function proc(d: any) {
const x = d.a * d.b;
return x;
}
// After
function calculateArea(dimensions: Dimensions) {
const area = dimensions.width * dimensions.height;
return area;
}Phase 6: REPEAT - Next Test
Goal
Continue cycle for next behavior.
Steps
1. Identify Next Behavior
Current: Validates email format
Next: Validates password strength
Future: Checks username availability2. Start New Cycle
Return to Phase 1 (RED) with new test:
test('rejects weak passwords', () => {
const result = validatePassword('123');
expect(result.valid).toBe(false);
expect(result.error).toBe('Password too short');
});3. Small Increments
Each test should be small step:
- ✓ Add email validation
- ✓ Add password validation
- ✓ Add username validation
Not:
- ✗ Add complete user registration system
Complete Example: Building Retry Function
Iteration 1: Basic Retry
RED:
test('retries failed operation once', async () => {
let attempts = 0;
const operation = () => {
attempts++;
if (attempts < 2) throw new Error('fail');
return 'success';
};
const result = await retryOperation(operation);
expect(attempts).toBe(2);
});GREEN:
async function retryOperation<T>(fn: () => Promise<T>): Promise<T> {
try {
return await fn();
} catch (e) {
return await fn(); // Retry once
}
}Iteration 2: Multiple Retries
RED:
test('retries failed operation 3 times', async () => {
let attempts = 0;
const operation = () => {
attempts++;
if (attempts < 3) throw new Error('fail');
return 'success';
};
const result = await retryOperation(operation);
expect(attempts).toBe(3);
});GREEN:
async function retryOperation<T>(fn: () => Promise<T>): Promise<T> {
for (let i = 0; i < 3; i++) {
try {
return await fn();
} catch (e) {
if (i === 2) throw e;
}
}
throw new Error('unreachable');
}Iteration 3: Handle Final Failure
RED:
test('throws error after all retries exhausted', async () => {
const operation = () => {
throw new Error('persistent failure');
};
await expect(retryOperation(operation)).rejects.toThrow('persistent failure');
});GREEN:
// Already passes! Implementation handles this.REFACTOR:
async function retryOperation<T>(
fn: () => Promise<T>,
maxRetries: number = 3
): Promise<T> {
let lastError: Error;
for (let attempt = 0; attempt < maxRetries; attempt++) {
try {
return await fn();
} catch (error) {
lastError = error as Error;
}
}
throw lastError!;
}Summary
The Cycle: 1. RED: Write failing test 2. VERIFY RED: Watch it fail correctly 3. GREEN: Minimal implementation 4. VERIFY GREEN: Watch it pass 5. REFACTOR: Improve code 6. REPEAT: Next behavior
Key Points:
- Each phase is mandatory
- Watch tests run (don't skip verification)
- Keep implementations minimal
- Refactor only when green
- Small increments, one behavior at a time
Related References
- Examples: Real-world TDD scenarios
- Philosophy: Why order matters
- Anti-patterns: Common mistakes
- Integration: TDD with other skills