Testing & TDD

Test-Driven Development (TDD)

22 min Lesson 3 of 35

Test-Driven Development (TDD)

Test-Driven Development (TDD) is a software development methodology where you write tests before writing the actual code. This approach flips the traditional development process and has profound effects on code quality, design, and confidence.

What is TDD?

TDD is a disciplined approach to software development that follows a simple cycle:

  1. Write a failing test that describes the behavior you want
  2. Write just enough code to make the test pass
  3. Refactor the code to improve quality while keeping tests green

Kent Beck's Quote: "Test-Driven Development is a way of managing fear during programming." — It provides confidence that your code works and will continue to work.

The Red-Green-Refactor Cycle

The heart of TDD is the Red-Green-Refactor cycle. Let's break down each phase:

🔴 Red: Write a Failing Test

Write a test for the next piece of functionality you want to add. The test will fail because the functionality doesn't exist yet.

<script> // Step 1: Write a failing test test('should calculate total price with tax', () => { const cart = new ShoppingCart(); cart.addItem({ price: 100 }); const total = cart.getTotalWithTax(0.10); // 10% tax expect(total).toBe(110); }); // ❌ Test fails: getTotalWithTax is not defined </script>

🟢 Green: Make the Test Pass

Write the simplest code possible to make the test pass. Don't worry about perfection—just make it work.

<script> // Step 2: Write minimal code to pass the test class ShoppingCart { constructor() { this.items = []; } addItem(item) { this.items.push(item); } getTotalWithTax(taxRate) { const subtotal = this.items.reduce((sum, item) => sum + item.price, 0); return subtotal + (subtotal * taxRate); } } // ✅ Test passes! </script>

🔵 Refactor: Improve the Code

Now that tests are passing, improve code quality without changing behavior. Tests ensure you don't break anything.

<script> // Step 3: Refactor for better quality class ShoppingCart { constructor() { this.items = []; } addItem(item) { this.items.push(item); } getSubtotal() { return this.items.reduce((sum, item) => sum + item.price, 0); } calculateTax(taxRate) { return this.getSubtotal() * taxRate; } getTotalWithTax(taxRate) { return this.getSubtotal() + this.calculateTax(taxRate); } } // ✅ Tests still pass, code is cleaner! </script>

Golden Rule: Never write new code unless you have a failing test. Never refactor without passing tests.

TDD in Practice: Complete Example

Let's build a password validator using TDD from scratch:

Iteration 1: Empty Password

<script> // 🔴 RED: Write failing test test('should reject empty password', () => { const validator = new PasswordValidator(); const result = validator.validate(''); expect(result.isValid).toBe(false); expect(result.errors).toContain('Password cannot be empty'); }); // ❌ PasswordValidator is not defined </script>
<script> // 🟢 GREEN: Make it pass class PasswordValidator { validate(password) { if (!password) { return { isValid: false, errors: ['Password cannot be empty'] }; } return { isValid: true, errors: [] }; } } // ✅ Test passes! </script>

Iteration 2: Minimum Length

<script> // 🔴 RED: Add new test test('should reject password shorter than 8 characters', () => { const validator = new PasswordValidator(); const result = validator.validate('short'); expect(result.isValid).toBe(false); expect(result.errors).toContain('Password must be at least 8 characters'); }); // ❌ Test fails </script>
<script> // 🟢 GREEN: Make it pass class PasswordValidator { validate(password) { const errors = []; if (!password) { errors.push('Password cannot be empty'); } else if (password.length < 8) { errors.push('Password must be at least 8 characters'); } return { isValid: errors.length === 0, errors }; } } // ✅ All tests pass! </script>

Iteration 3: Refactor

<script> // 🔵 REFACTOR: Improve design class PasswordValidator { constructor() { this.minLength = 8; } validate(password) { const errors = []; if (!password) { errors.push('Password cannot be empty'); return this.createResult(errors); } if (password.length < this.minLength) { errors.push(`Password must be at least ${this.minLength} characters`); } return this.createResult(errors); } createResult(errors) { return { isValid: errors.length === 0, errors }; } } // ✅ Tests still pass, code is more maintainable! </script>

Benefits of TDD

TDD provides numerous advantages that make it worth the initial learning curve:

1. Better Design

Writing tests first forces you to think about interfaces and design before implementation. This leads to more modular, loosely coupled code.

2. Less Debugging

Bugs are caught immediately when tests fail. You know exactly what broke and when.

3. Documentation

Tests serve as living documentation showing how code should be used and what it does.

4. Confidence to Refactor

With comprehensive tests, you can refactor fearlessly knowing tests will catch any regressions.

5. Simpler Code

TDD encourages writing only the code needed to pass tests, avoiding over-engineering.

6. Faster Development

While TDD seems slower initially, it speeds up development by reducing debugging time and preventing bugs.

Research Finding: Studies show that TDD can reduce defect density by 40-90% compared to traditional development, with only a 15-35% increase in development time.

TDD Best Practices

  • Start Simple: Begin with the simplest test case and build up complexity gradually
  • One Test at a Time: Focus on making one test pass before writing the next
  • Small Steps: Take baby steps—write small tests and small implementations
  • Test Behavior, Not Implementation: Focus on what the code should do, not how it does it
  • Keep Tests Fast: Fast tests encourage running them frequently
  • Refactor Regularly: Don't skip the refactor step—it's essential for code quality
  • Delete Code: If you write code that isn't needed to pass a test, delete it

Common TDD Mistakes

Avoid These Pitfalls:

  • Writing Tests After: Writing code first defeats the purpose of TDD
  • Testing Too Much: Don't test implementation details or framework code
  • Skipping Refactor: The refactor step is crucial for code quality
  • Large Steps: Taking big leaps makes it harder to identify problems
  • Not Running Tests Frequently: Run tests after every small change
  • Brittle Tests: Tests shouldn't break when you refactor implementation

When to Use TDD

TDD is particularly effective in these scenarios:

  • Complex Business Logic: When logic is intricate and error-prone
  • Public APIs: When you need to ensure contracts are maintained
  • Bug Fixes: Write a failing test that reproduces the bug, then fix it
  • Refactoring: Tests provide safety net when restructuring code
  • Learning New Tech: TDD helps understand new libraries or frameworks

When TDD Might Not Fit

TDD isn't always the best approach for every situation:

  • Prototyping: Rapid experimentation may be hindered by TDD
  • UI Design: Visual design iteration is hard to TDD
  • Unclear Requirements: When you don't know what you're building yet
  • Trivial Code: Simple getters/setters don't benefit from TDD

Pragmatic Approach: You don't have to use TDD for 100% of your code. Use it where it adds value and skip it where it doesn't.

TDD vs Traditional Testing

Aspect Traditional Testing TDD
When to Write Tests After code is written Before code is written
Focus Verifying implementation Defining behavior
Code Design Design first, test later Design emerges from tests
Test Coverage Often incomplete 100% by definition
Confidence Tests may miss edge cases High confidence in behavior

TDD Workflow Example

Here's a complete TDD workflow for building a string trimmer utility:

<script> // Test Suite describe('StringTrimmer', () => { // Iteration 1: Basic trimming test('should remove leading and trailing spaces', () => { const result = trimString(' hello '); expect(result).toBe('hello'); }); // ✅ Implement and pass // Iteration 2: Empty string test('should handle empty string', () => { const result = trimString(''); expect(result).toBe(''); }); // ✅ Implement and pass // Iteration 3: No spaces test('should return same string if no spaces', () => { const result = trimString('hello'); expect(result).toBe('hello'); }); // ✅ Already passes! // Iteration 4: Multiple spaces test('should handle multiple spaces', () => { const result = trimString(' hello world '); expect(result).toBe('hello world'); }); // ✅ Implement and pass // Iteration 5: Null/undefined test('should handle null and undefined', () => { expect(trimString(null)).toBe(''); expect(trimString(undefined)).toBe(''); }); // ✅ Implement and pass }); </script>

TDD Exercise

Practice TDD by building a FizzBuzz function. Follow the Red-Green-Refactor cycle:

Requirements:

  • Numbers divisible by 3 return "Fizz"
  • Numbers divisible by 5 return "Buzz"
  • Numbers divisible by both return "FizzBuzz"
  • Other numbers return the number as a string

Steps:

  1. Write a test for numbers divisible by 3
  2. Write minimal code to pass
  3. Write a test for numbers divisible by 5
  4. Update code to pass both tests
  5. Write a test for numbers divisible by both
  6. Update code to pass all tests
  7. Write a test for other numbers
  8. Complete implementation
  9. Refactor if needed

Summary

Test-Driven Development is a powerful methodology that improves code quality, design, and confidence. By following the Red-Green-Refactor cycle, you write better tests, cleaner code, and catch bugs earlier. While TDD has a learning curve, the benefits in code quality and maintainability make it worthwhile for most development scenarios.

Key Takeaways:

  • TDD follows the Red-Green-Refactor cycle
  • Write failing tests before writing code
  • Write minimal code to make tests pass
  • Refactor to improve quality while keeping tests green
  • TDD leads to better design and fewer bugs
  • Use TDD where it adds value, not everywhere blindly