Files
gh-samuelgarrett-claude-cod…/commands/tdd-expert.md
2025-11-30 08:53:46 +08:00

12 KiB

TDD Expert

Description: Master test-driven development with the Red-Green-Refactor cycle for reliable, maintainable code

Core Philosophy

Test-Driven Development (TDD) is not just about testing - it's a design methodology that leads to better code architecture, fewer bugs, and higher confidence in changes.

The Red-Green-Refactor Cycle

🔴 RED: Write a Failing Test

Write the smallest test that would verify the next piece of functionality

  • Test should fail for the right reason
  • Test should be specific and focused
  • Test should describe desired behavior, not implementation

🟢 GREEN: Make It Pass

Write the minimal code to make the test pass

  • Don't worry about perfect code yet
  • Simplest solution that works
  • It's OK to hardcode initially

🔵 REFACTOR: Improve the Code

Clean up both test and production code

  • Remove duplication
  • Improve names
  • Extract functions/classes
  • Optimize if needed
  • Tests must still pass

♻️ REPEAT

Continue the cycle for the next piece of functionality

TDD Workflow

Starting a New Feature

1. [ ] Write a list of test cases to implement
2. [ ] Pick the simplest test case
3. [ ] Write a failing test (RED)
4. [ ] Run the test - verify it fails
5. [ ] Write minimal code to pass (GREEN)
6. [ ] Run the test - verify it passes
7. [ ] Refactor (REFACTOR)
8. [ ] Run tests - verify still passing
9. [ ] Pick next test case
10. [ ] Repeat

Test Case Brainstorming

Before coding, list out test scenarios:

Feature: User Login

Test Cases:
- [ ] Valid credentials → success
- [ ] Invalid password → error
- [ ] Nonexistent user → error
- [ ] Empty email → validation error
- [ ] Empty password → validation error
- [ ] SQL injection attempt → sanitized
- [ ] Rate limiting → too many attempts blocked
- [ ] Session creation on success
- [ ] Failed attempts logged

Writing Good Tests

The AAA Pattern

test('should calculate total price with tax', () => {
  // ARRANGE: Set up test data
  const items = [
    { price: 10, quantity: 2 },
    { price: 5, quantity: 1 }
  ];
  const taxRate = 0.1;

  // ACT: Execute the code under test
  const result = calculateTotal(items, taxRate);

  // ASSERT: Verify the result
  expect(result).toBe(27.5); // (10*2 + 5*1) * 1.1
});

Test Naming Conventions

Good test names are descriptive and follow patterns:

Pattern 1: should_ExpectedBehavior_When_StateUnderTest
  ✓ should_ReturnZero_When_CartIsEmpty
  ✓ should_ThrowError_When_PriceIsNegative

Pattern 2: When_StateUnderTest_Expect_ExpectedBehavior
  ✓ When_CartIsEmpty_Expect_ZeroTotal
  ✓ When_PriceIsNegative_Expect_Error

Pattern 3: GivenContext_WhenAction_ThenOutcome
  ✓ GivenEmptyCart_WhenCalculatingTotal_ThenReturnsZero
  ✓ GivenInvalidPrice_WhenCalculating_ThenThrowsError

Test Organization

describe('ShoppingCart', () => {
  describe('addItem', () => {
    it('should add item to empty cart', () => { });
    it('should increment quantity for existing item', () => { });
    it('should throw error for negative quantity', () => { });
  });

  describe('removeItem', () => {
    it('should remove item from cart', () => { });
    it('should do nothing if item not in cart', () => { });
  });

  describe('calculateTotal', () => {
    it('should return 0 for empty cart', () => { });
    it('should sum all item prices', () => { });
    it('should apply tax rate correctly', () => { });
  });
});

Types of Tests

1. Unit Tests

Test individual functions/methods in isolation

// Testing a pure function
describe('formatCurrency', () => {
  it('should format USD correctly', () => {
    expect(formatCurrency(1234.56, 'USD')).toBe('$1,234.56');
  });

  it('should handle zero', () => {
    expect(formatCurrency(0, 'USD')).toBe('$0.00');
  });

  it('should round to 2 decimals', () => {
    expect(formatCurrency(1.234, 'USD')).toBe('$1.23');
  });
});

2. Integration Tests

Test multiple components working together

// Testing API endpoint with database
describe('POST /api/users', () => {
  it('should create user and return ID', async () => {
    const userData = { email: 'test@example.com', name: 'Test' };
    const response = await request(app).post('/api/users').send(userData);

    expect(response.status).toBe(201);
    expect(response.body).toHaveProperty('id');

    // Verify in database
    const user = await db.users.findById(response.body.id);
    expect(user.email).toBe(userData.email);
  });
});

3. End-to-End Tests

Test complete user workflows

// Testing full user journey
describe('User Registration Flow', () => {
  it('should allow new user to register and login', async () => {
    // Visit registration page
    await page.goto('/register');

    // Fill form
    await page.fill('#email', 'newuser@example.com');
    await page.fill('#password', 'SecurePass123!');
    await page.click('button[type="submit"]');

    // Should redirect to dashboard
    await expect(page).toHaveURL('/dashboard');
    await expect(page.locator('.welcome-message')).toContainText('Welcome');
  });
});

Test Doubles (Mocks, Stubs, Spies)

When to Use Mocks

// Mock external dependencies for unit tests
describe('UserService', () => {
  it('should send welcome email on registration', async () => {
    // Mock the email service
    const mockEmailService = {
      send: jest.fn().mockResolvedValue(true)
    };

    const userService = new UserService(mockEmailService);
    await userService.registerUser({ email: 'test@example.com' });

    // Verify email was sent
    expect(mockEmailService.send).toHaveBeenCalledWith({
      to: 'test@example.com',
      template: 'welcome'
    });
  });
});

Dependency Injection for Testability

// Bad: Hard to test
class UserService {
  async createUser(data: UserData) {
    await Database.save(data); // Direct dependency
    await EmailService.send(data.email); // Hard to mock
  }
}

// Good: Easy to test
class UserService {
  constructor(
    private db: DatabaseService,
    private email: EmailService
  ) {}

  async createUser(data: UserData) {
    await this.db.save(data);
    await this.email.send(data.email);
  }
}

// Now we can inject mocks in tests

TDD Anti-Patterns to Avoid

Testing Implementation Details

// Bad: Tests implementation
test('should call helper function 3 times', () => {
  const spy = jest.spyOn(obj, 'helperFunc');
  obj.mainFunc();
  expect(spy).toHaveBeenCalledTimes(3);
});

// Good: Tests behavior
test('should return correct result', () => {
  expect(obj.mainFunc()).toBe(expectedResult);
});

Overly Complex Tests

// Bad: Test is harder to understand than the code
test('complex test', () => {
  const data = generateComplexData();
  for (let i = 0; i < data.length; i++) {
    if (data[i].type === 'A') {
      // ... complex logic
    }
  }
  expect(result).toBe(/* computed value */);
});

// Good: Simple, focused test
test('should process type A items correctly', () => {
  const item = { type: 'A', value: 10 };
  expect(processItem(item)).toBe(20);
});

Test Interdependence

// Bad: Tests depend on each other
let userId;
test('create user', () => {
  userId = createUser(); // Side effect
});
test('update user', () => {
  updateUser(userId); // Depends on previous test
});

// Good: Each test is independent
test('create user', () => {
  const userId = createUser();
  expect(userId).toBeDefined();
});
test('update user', () => {
  const userId = createUser(); // Setup within test
  const result = updateUser(userId);
  expect(result).toBe(true);
});

Test Coverage Targets

Aim for High Coverage, Not 100%

  • Statements: 80%+ (most lines executed)
  • Branches: 75%+ (most if/else paths tested)
  • Functions: 90%+ (most functions tested)
  • Lines: 80%+ (most lines covered)

What to Prioritize

  1. Critical business logic - Must have 100% coverage
  2. Complex algorithms - High coverage with edge cases
  3. Public APIs - All entry points tested
  4. Error handling - All error paths verified

What's OK to Skip

  • Trivial getters/setters
  • Framework boilerplate
  • Third-party library code
  • Configuration files

TDD Best Practices

1. Write Tests First (Truly!)

❌ Bad: Write code first, then add tests
✓ Good: Write failing test, then make it pass

2. One Assert Per Test (Usually)

// Prefer focused tests
test('should create user with correct email', () => {
  const user = createUser({ email: 'test@example.com' });
  expect(user.email).toBe('test@example.com');
});

test('should create user with hashed password', () => {
  const user = createUser({ password: 'secret' });
  expect(user.password).not.toBe('secret');
  expect(user.password).toMatch(/^\$2[aby]\$/);
});

3. Fast Tests

- Unit tests: < 10ms each
- Integration tests: < 100ms each
- E2E tests: < 5s each

If slower, consider:
- Using test doubles
- Running in parallel
- Optimizing setup/teardown

4. Reliable Tests

Tests should:
- ✓ Pass consistently (no flakiness)
- ✓ Fail for the right reasons
- ✓ Be deterministic
- ✓ Not depend on external state
- ✓ Clean up after themselves

5. Readable Tests

// Tests are documentation - make them clear
test('should allow user to checkout with valid cart', () => {
  // Arrange: Create a realistic scenario
  const cart = createCart();
  cart.addItem({ id: 1, name: 'Widget', price: 9.99 });
  const user = createUser({ hasPaymentMethod: true });

  // Act: Perform the action
  const result = checkout(cart, user);

  // Assert: Verify expected outcome
  expect(result.status).toBe('success');
  expect(result.orderId).toBeDefined();
});

TDD Workflow with Todo System

When implementing a feature with TDD:

Feature: Shopping Cart

[ ] Write test cases list
[ ] RED: Test for adding first item
[ ] GREEN: Implement add item (simple)
[ ] REFACTOR: Clean up code
[ ] RED: Test for adding duplicate item
[ ] GREEN: Implement quantity increment
[ ] REFACTOR: Extract logic
[ ] RED: Test for removing item
[ ] GREEN: Implement remove item
[ ] REFACTOR: Final cleanup
[ ] Verify all tests pass
[ ] Check coverage report

When to Use This Skill

  • When starting any new feature or function
  • When fixing bugs (write test that reproduces bug first)
  • When refactoring (tests ensure behavior doesn't change)
  • When code quality and reliability are critical
  • When you want to move fast with confidence

Example: TDD Session

Task: Implement a function to validate email addresses

TEST LIST:
- [ ] Valid email with standard format
- [ ] Invalid email without @
- [ ] Invalid email without domain
- [ ] Empty string
- [ ] Null/undefined input
- [ ] Email with special characters
- [ ] Email with subdomain

---

🔴 RED - Test 1:
```javascript
test('should return true for valid email', () => {
  expect(isValidEmail('user@example.com')).toBe(true);
});
// ❌ FAILS: isValidEmail is not defined

🟢 GREEN - Minimal implementation:

function isValidEmail(email) {
  return email === 'user@example.com'; // Hardcoded!
}
// ✅ PASSES

🔴 RED - Test 2:

test('should return false for email without @', () => {
  expect(isValidEmail('userexample.com')).toBe(false);
});
// ❌ FAILS: Returns true (because of hardcode)

🟢 GREEN - Real implementation:

function isValidEmail(email) {
  return email.includes('@');
}
// ✅ PASSES both tests

🔴 RED - Test 3:

test('should return false for empty string', () => {
  expect(isValidEmail('')).toBe(false);
});
// ❌ FAILS

🟢 GREEN - Improved:

function isValidEmail(email) {
  return email && email.includes('@') && email.includes('.');
}
// ✅ PASSES all tests

🔵 REFACTOR - Polish:

function isValidEmail(email) {
  if (!email) return false;
  const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
  return emailRegex.test(email);
}
// ✅ PASSES all tests, cleaner implementation

Continue with remaining test cases...


---

**Remember**: TDD feels slower at first, but pays dividends in fewer bugs, better design, and faster debugging. Trust the process!