Test-Driven Development (TDD)
Also Known As
TL;DR
Explanation
TDD follows a three-step cycle: Red — write a test for the desired behaviour that fails because the implementation does not exist yet; Green — write the minimum code needed to make the test pass (no more); Refactor — improve the implementation while keeping all tests green. This cycle runs in minutes, not hours. TDD benefits: forces design thinking before coding (if a class is hard to test, it is poorly designed); produces a complete test suite as a byproduct; enables confident refactoring; provides executable documentation of expected behaviour. In PHP, PHPUnit is the standard test framework; Pest provides a more expressive syntax. TDD does not mean writing every line of code test-first — it means starting with tests for behaviour, especially for business logic, edge cases, and error conditions. UI and integration tests are often written after because they are slower and harder to write first.
Common Misconception
Why It Matters
Common Mistakes
- Writing tests after all the code is complete — this is testing, not TDD; the design benefits only come from writing tests first.
- Writing tests that test implementation details rather than behaviour — test what the method does, not how it does it.
- Skipping the Refactor step — Green does not mean done; code written to pass tests fast is often messy and must be cleaned up.
- Not running tests in under one second — if the test suite is slow, developers stop running it; keep unit tests fast by avoiding database and HTTP calls.
Avoid When
- Exploratory or spike code where the design is unknown — write tests after the spike, before productionising.
- Simple getter/setter or pure configuration code where tests add noise without catching meaningful bugs.
- Legacy code with no test infrastructure — retrofitting TDD into an untestable codebase requires refactoring first.
- Deadline pressure that makes writing tests first slower than the value it provides in that specific context.
When To Use
- Complex business logic with many edge cases — tests drive out the cases you would otherwise miss.
- APIs and interfaces that must be stable — writing the test first defines the contract before implementation.
- Bug fixes — write a failing test that reproduces the bug, then fix it; the test prevents regression.
- Refactoring — a passing test suite gives you the confidence to change internals without breaking behaviour.
Code Examples
// Code written first, tested after — gaps in coverage
class PriceCalculator {
public function calculate(int $qty, float $price, float $discount): float {
return ($qty * $price) * (1 - $discount / 100);
}
}
// Tests written after often miss edge cases:
// What if qty is 0? discount > 100? price is negative?
// TDD — test written first reveals edge cases before coding
class PriceCalculatorTest extends TestCase {
public function test_calculates_discounted_price(): void {
$calc = new PriceCalculator();
$this->assertEquals(90.0, $calc->calculate(1, 100.0, 10));
}
public function test_zero_quantity_returns_zero(): void {
$this->assertEquals(0.0, (new PriceCalculator())->calculate(0, 100.0, 0));
}
public function test_rejects_discount_over_100(): void {
$this->expectException(InvalidArgumentException::class);
(new PriceCalculator())->calculate(1, 100.0, 110);
// This test FAILS first — now implement the validation
}
}