Test-Driven Development (TDD)
Test-Driven Development (TDD)
Section titled โTest-Driven Development (TDD)โTDD is a development practice where you write a failing test before writing the production code to make it pass. The discipline is: tests drive the design.
The Red-Green-Refactor Cycle
Section titled โThe Red-Green-Refactor Cycleโ1. RED โ Write a failing test for the behaviour you want2. GREEN โ Write the minimum code to make the test pass3. REFACTOR โ Clean up the code without breaking tests โ RepeatThe test failing first confirms it actually tests something. Writing minimum code to pass forces you to think about the interface before the implementation.
A TDD Example
Section titled โA TDD ExampleโStep 1 โ Red (write the failing test):
def test_apply_discount_reduces_total(): order = Order(items=[Item(price=100), Item(price=50)]) order.apply_discount(percent=10) assert order.total() == 135.0 # 150 - 15Running this fails โ Order doesnโt exist yet.
Step 2 โ Green (minimum code to pass):
class Item: def __init__(self, price: float): self.price = price
class Order: def __init__(self, items: list[Item]): self._items = items self._discount = 0.0
def apply_discount(self, percent: float) -> None: self._discount = percent
def total(self) -> float: subtotal = sum(i.price for i in self._items) return subtotal * (1 - self._discount / 100)Test passes. Keep it minimal โ resist the urge to add features not yet tested.
Step 3 โ Refactor:
# Order.total is correct but discount handling could be clearer# Rename _discount to _discount_percent for clarity# Extract subtotal calculationTests still pass. Commit. Repeat for the next behaviour.
Benefits of TDD
Section titled โBenefits of TDDโ- Testable design โ code written test-first is almost always more modular and easier to test
- Safety net โ comprehensive tests make refactoring safe
- Documentation โ tests describe exactly what the system does
- Fewer bugs โ you think through edge cases before coding
When TDD Works Well
Section titled โWhen TDD Works Wellโ- Business logic with clear inputs and outputs
- Algorithms (parsers, calculators, validators)
- Pure functions
- Domain models
When TDD is Harder
Section titled โWhen TDD is Harderโ- UI layout and visual design
- Exploratory work where requirements are unclear
- Integration with third-party systems (hard to mock effectively)
- Performance optimisation
In these cases, write tests after the fact โ itโs still worth having them.
TDD in TypeScript (Jest)
Section titled โTDD in TypeScript (Jest)โ// tests/password-validator.test.ts โ write first
describe('PasswordValidator', () => { it('rejects passwords shorter than 8 characters', () => { expect(validatePassword('abc123')).toEqual({ valid: false, errors: ['Password must be at least 8 characters'], }); });
it('rejects passwords without a number', () => { expect(validatePassword('Password')).toEqual({ valid: false, errors: ['Password must contain at least one number'], }); });
it('accepts a valid password', () => { expect(validatePassword('Password1')).toEqual({ valid: true, errors: [] }); });});Then implement to make the tests pass:
interface ValidationResult { valid: boolean; errors: string[];}
export function validatePassword(password: string): ValidationResult { const errors: string[] = [];
if (password.length < 8) { errors.push('Password must be at least 8 characters'); }
if (!/\d/.test(password)) { errors.push('Password must contain at least one number'); }
return { valid: errors.length === 0, errors };}Outside-In TDD (London School)
Section titled โOutside-In TDD (London School)โStart from the outside (acceptance/integration tests) and work inward, mocking collaborators:
- Write a failing integration test for the feature
- Write unit tests for each component it depends on (mock the next layer)
- Implement each component
- Watch the integration test pass
This style drives out interfaces from usage โ collaborators are designed to fit the testโs needs.
Inside-Out TDD (Chicago School)
Section titled โInside-Out TDD (Chicago School)โStart with the innermost, smallest unit and build upward:
- Write a failing unit test for the lowest-level component
- Make it pass
- Build the next layer using the real implementation underneath
Less mocking, more real integration as you go up.
Common Pitfalls
Section titled โCommon Pitfallsโ- Writing the test after coding (โtest-afterโ) loses the design benefit
- Testing implementation rather than behaviour makes refactoring painful
- Writing too much code in the Green phase โ stay minimal
- Skipping the Refactor phase โ tech debt accumulates fast