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:

  1. 🔴 Test: free over £50 → 🟢 Implement
  2. 🔴 Test: £4.99 under £50 → 🟢 Implement
  3. 🔴 Test: exactly £50 boundary → 🟢 Implement
  4. 🔵 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

  1. Understand the bug - Reproduce it manually. Be precise about what’s wrong: what’s the input, what’s the expected output, what actually happens?
  2. 🔴 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.
  3. 🟢 Fix the bug - Write the minimum code to make the test pass. Nothing more.
  4. 🔵 Refactor - Clean up the fix if needed. Your test protects you.
  5. 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

MistakeWhy it’s a problem
Writing the test after fixing the bugYou never proved the test actually catches the bug. It might pass for the wrong reason.
Testing the implementation, not the behaviourexpect(fn)->toCall('checkZero') is brittle. Test what the user sees: expect(shippingCost(0))->toBe(0.0).
Fixing more than the bugStay focused. Fix this bug. If you spot other issues, note them down and TDD those separately.
Skipping the refactor stepThe 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.