Skip to content

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.

1. RED โ€” Write a failing test for the behaviour you want
2. GREEN โ€” Write the minimum code to make the test pass
3. REFACTOR โ€” Clean up the code without breaking tests
โ€” Repeat

The test failing first confirms it actually tests something. Writing minimum code to pass forces you to think about the interface before the implementation.

Step 1 โ€” Red (write the failing test):

tests/test_order.py
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 - 15

Running this fails โ€” Order doesnโ€™t exist yet.

Step 2 โ€” Green (minimum code to pass):

src/order.py
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 calculation

Tests still pass. Commit. Repeat for the next behaviour.

  • 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
  • Business logic with clear inputs and outputs
  • Algorithms (parsers, calculators, validators)
  • Pure functions
  • Domain models
  • 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.

// 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:

src/password-validator.ts
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 };
}

Start from the outside (acceptance/integration tests) and work inward, mocking collaborators:

  1. Write a failing integration test for the feature
  2. Write unit tests for each component it depends on (mock the next layer)
  3. Implement each component
  4. Watch the integration test pass

This style drives out interfaces from usage โ€” collaborators are designed to fit the testโ€™s needs.

Start with the innermost, smallest unit and build upward:

  1. Write a failing unit test for the lowest-level component
  2. Make it pass
  3. Build the next layer using the real implementation underneath

Less mocking, more real integration as you go up.

  • 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