The Red Green Refactor Cycle Is Simpler Than Most TDD Articles Make It Look
by Eric Hanson, Backend Developer at Clean Systems Consulting
The Mechanics Without the Philosophy
Red-green-refactor is a tight feedback loop, not a methodology you adopt. Each iteration takes between ninety seconds and five minutes. You run the tests constantly. The color of the test output is a signal: red means there is work to do, green means the behavior is correct, and refactor means you can now improve the code without changing its behavior.
That is the entire cycle. The philosophy is interesting but optional for getting started. The mechanics are what matter.
Red: Write One Failing Test
Write a single test for the next behavior you want the code to have. Run the test. It fails — either with a compilation error (the class or method does not exist yet) or with an assertion failure (the method exists but returns the wrong thing).
The test should be specific enough to be falsified: not "it returns something" but "it returns 85.0 given a price of 100 and a discount rate of 0.15."
The most common mistake in this phase: writing too much test at once. One behavior, one test. If you find yourself writing a test with five assertions covering three scenarios, stop and pick the simplest one.
# Red: Write this, run it, watch it fail
def test_no_discount_applied_when_rate_is_zero():
calculator = PriceCalculator()
result = calculator.apply_discount(price=100.0, rate=0.0)
assert result == 100.0
# NameError: name 'PriceCalculator' is not defined
# Good. Now make it pass.
Green: Write the Minimum Code to Pass
Not the correct code, not the complete code — the minimum code that makes this specific test pass. This constraint is intentional and important.
If the test expects apply_discount(100.0, 0.0) to return 100.0, the minimum implementation is:
class PriceCalculator:
def apply_discount(self, price: float, rate: float) -> float:
return 100.0 # Hardcoded. Ugly. Correct for now.
This is not the final implementation. It is a temporary fake that passes the current test. When you write the next test — apply_discount(200.0, 0.0) returns 200.0, for instance — this fake will fail, forcing you to generalize.
Developers resist this step because it feels like writing bad code deliberately. The point is that you should only generalize the implementation when a test forces you to. This prevents over-engineering: you never build complexity the current tests do not require.
# Next test forces generalization
def test_no_discount_when_rate_is_zero_any_price():
calculator = PriceCalculator()
assert calculator.apply_discount(price=200.0, rate=0.0) == 200.0
assert calculator.apply_discount(price=50.0, rate=0.0) == 50.0
# Now minimum implementation must be the real formula
class PriceCalculator:
def apply_discount(self, price: float, rate: float) -> float:
return price * (1 - rate)
Refactor: Improve Without Changing Behavior
The test is green. Now you can clean up: rename variables, extract helper methods, remove duplication, simplify conditionals. Whatever improves readability or structure without changing what the code does.
Run the tests after every change. If they go red, you changed behavior — undo the last change. The tests are your safety net during refactoring, which is why TDD-produced code is relatively easy to improve over time.
Refactoring is often skipped under time pressure. This is the step where the design quality that TDD advocates promise actually gets built. Skipping it means you get tested code but not cleaner code.
The Cycle in a Full Example
# Iteration 1: Red
def test_full_price_when_no_discount():
assert PriceCalculator().apply_discount(100.0, 0.0) == 100.0
# Green (fake): return 100.0
# Iteration 2: Red (forces generalization)
def test_discount_reduces_price():
assert PriceCalculator().apply_discount(100.0, 0.1) == 90.0
# Green (real formula): return price * (1 - rate)
# Iteration 3: Red (boundary case)
def test_full_discount_zeroes_price():
assert PriceCalculator().apply_discount(100.0, 1.0) == 0.0
# Green: existing formula handles this
# Iteration 4: Red (invalid input)
def test_negative_discount_rate_raises():
with pytest.raises(ValueError):
PriceCalculator().apply_discount(100.0, -0.1)
# Green: add validation
# Refactor: add type hints, rename for clarity, extract validation
Five iterations, roughly ten minutes, and you have a fully specified, tested implementation that handles the happy path and the failure cases. The tests drove you to think about the negative rate case, which you might have missed implementation-first.
The cycle is that simple. The skill is keeping each iteration small enough that you always know exactly what you are building next.