I’ve been looking at adding some more “learning” type material to my posts, some of this might be obvious, and it’s more for a reference for myself to look back on, but hey you might find it useful I dunno!?
So what is it?
Think of it as a traffic light cycle you repeat for every small piece of behaviour.
🔴 Red - Write a failing test
Write a test for behaviour that doesn’t exist yet. Run it. It fails. The failure proves your test is actually checking something real.
it('returns free shipping for orders over £50', function () {
expect(shippingCost(51))->toBe(0);
});
This fails because shippingCost doesn’t exist. That’s red.
🟢 Green - Write the minimum code to pass
Not the best code. Not the cleanest code. Just enough to make the test go green:
function shippingCost(float $total): float {
return $total > 50 ? 0 : 4.99;
}
Run the test - it passes. That’s green.
🔵 Refactor - Improve without changing behaviour
Now that you have a passing test as a safety net, clean up. Rename things, extract logic, apply patterns - the test tells you if you break anything.
Why this order matters
- Red first stops you writing code you don’t need. The test defines the requirement before you solve it.
- Green second keeps you focused - solve this one thing, don’t gold-plate.
- Refactor third means you’re never refactoring blind. The test has your back.
The cycle in practice
Each cycle should be small - minutes, not hours. Build up behaviour across several cycles:
- 🔴 Test: free over £50 → 🟢 Implement
- 🔴 Test: £4.99 under £50 → 🟢 Implement
- 🔴 Test: exactly £50 boundary → 🟢 Implement
- 🔵 Refactor: maybe extract thresholds into config
Each cycle adds one behaviour. You end up with a suite of tests that document exactly what your code does.
Bug Fix TDD
Bug fix TDD is the easiest way to start practising TDD because you already have a clear problem to solve. The process follows the same red-green-refactor cycle, but your starting point is a real bug rather than a new feature.
The process
- Understand the bug - Reproduce it manually. Be precise about what’s wrong: what’s the input, what’s the expected output, what actually happens?
- 🔴 Write a test that reproduces the bug - This test should fail right now because the bug still exists. If the test passes, you haven’t captured the actual bug.
- 🟢 Fix the bug - Write the minimum code to make the test pass. Nothing more.
- 🔵 Refactor - Clean up the fix if needed. Your test protects you.
- Run the full test suite - Make sure your fix hasn’t broken anything else.
Worked example
The bug: Users report that applying a 100% discount still charges £4.99 shipping. Orders with a zero total should be free shipping.
Step 1: Write the failing test
it('returns free shipping when order total is zero after discount', function () {
expect(shippingCost(0))->toBe(0.0);
});
Run it - it fails. shippingCost(0) returns 4.99 because the current logic only checks > 50. That’s your 🔴 red.
Step 2: Fix the bug
function shippingCost(float $total): float {
if ($total <= 0) {
return 0;
}
return $total > 50 ? 0 : 4.99;
}
Run the test - it passes. 🟢 Green.
Step 3: Refactor
Looking at this, maybe the logic reads better consolidated:
function shippingCost(float $total): float {
if ($total <= 0 || $total > 50) {
return 0;
}
return 4.99;
}
All tests still pass. 🔵 Done.
Why this works so well
- You prove you understand the bug. If you can’t write a test that fails, you haven’t pinpointed the real issue - you’re guessing.
- You prove the fix works. The test going from red to green is concrete evidence, not “it seems to work when I click around.”
- The bug can never come back. That test runs in CI forever. If someone accidentally reintroduces the same logic error six months later, the test catches it. This is called a regression test.
- It builds your test suite organically. You’re not sitting down to write 100 tests at once. Every bug you fix adds one. Over time the coverage grows where it matters most - around the code that’s actually broken in practice.
Common mistakes
| Mistake | Why it’s a problem |
|---|---|
| Writing the test after fixing the bug | You never proved the test actually catches the bug. It might pass for the wrong reason. |
| Testing the implementation, not the behaviour | expect(fn)->toCall('checkZero') is brittle. Test what the user sees: expect(shippingCost(0))->toBe(0.0). |
| Fixing more than the bug | Stay focused. Fix this bug. If you spot other issues, note them down and TDD those separately. |
| Skipping the refactor step | The quick fix that makes the test pass is often messy. Take 30 seconds to clean up while context is fresh. |
When to use bug fix TDD
Every time. Seriously. Any bug worth fixing is worth a test. If you find yourself saying “this is too small to test” - that’s exactly the kind of bug that will come back and waste an afternoon in six months.
Key takeaways
- TDD feels slower at first, but you spend far less time debugging later because every behaviour is covered from the start.
- Each red-green-refactor cycle should be small - one behaviour at a time.
- Tests written first describe what the code should do, becoming living documentation.