The Red-Green-Refactor Cycle: TDD's Core Loop Explained
Test-Driven Development has a reputation for being a discipline that sounds great in theory but falls apart under deadline pressure. That reputation is wrong, and the reason is usually a misunderstanding of what TDD actually is. TDD is not "write all your tests before you write any code." It is a tight feedback loop with three phases: Red, Green, Refactor. Once you internalize that loop, writing tests first stops feeling like overhead and starts feeling like the fastest way to build software correctly.
What the Cycle Is
Red: Write a test for behavior that does not exist yet. Run it. Watch it fail. The red phase is not a mistake — it is the point. A failing test is a precise specification of what you are about to build.
Green: Write the minimum code necessary to make the test pass. Not clean code. Not extensible code. Just enough to go from red to green. This constraint is intentional. Your only job in the green phase is to satisfy the test.
Refactor: Now that the test is passing, clean up the code. Extract functions, rename variables, remove duplication. The test suite is your safety net. If you break something during refactor, the tests will tell you immediately.
Then you start over. The cycle is typically 5–15 minutes long. It is not a waterfall with three big phases — it is a rapid loop you run dozens of times in a single coding session.
Why It Works
The red-green-refactor cycle solves three problems simultaneously.
It forces you to think about the interface before the implementation. When you write the test first, you are a consumer of your own code before it exists. This naturally pushes you toward cleaner APIs. Functions that are hard to test are usually functions with too many responsibilities or too many hidden dependencies.
It gives you continuous feedback. Without TDD, you might write 200 lines of code and then discover that your approach has a fundamental flaw. With TDD, you find out within 10 minutes that something is wrong, because the test tells you.
It separates concerns cleanly. The green phase and the refactor phase have different goals. Keeping them separate prevents the common failure mode of trying to write perfect code the first time, which leads to paralysis.
A Practical Example in JavaScript
Suppose you are building a function that calculates the discount price for an item.
Red — write the failing test:
// discount.test.js
const { calculateDiscount } = require('./discount');
test('applies 10% discount to a $100 item', () => {
expect(calculateDiscount(100, 10)).toBe(90);
});
test('returns original price when discount is 0', () => {
expect(calculateDiscount(50, 0)).toBe(50);
});
test('throws when discount exceeds 100%', () => {
expect(() => calculateDiscount(100, 110)).toThrow('Discount cannot exceed 100%');
});Run these tests. They all fail because discount.js does not exist. You are in the red phase.
Green — make the tests pass:
// discount.js
function calculateDiscount(price, discountPercent) {
if (discountPercent > 100) {
throw new Error('Discount cannot exceed 100%');
}
return price - (price * discountPercent / 100);
}
module.exports = { calculateDiscount };Run the tests again. All green. The code is not beautiful, but it works.
Refactor — clean up while staying green:
// discount.js
const MAX_DISCOUNT = 100;
function calculateDiscount(price, discountPercent) {
if (discountPercent > MAX_DISCOUNT) {
throw new Error(`Discount cannot exceed ${MAX_DISCOUNT}%`);
}
const discountAmount = price * (discountPercent / 100);
return price - discountAmount;
}
module.exports = { calculateDiscount };Run the tests. Still green. You extracted a constant, added an intermediate variable for clarity, and made the error message dynamic. None of that changed behavior — the tests confirm it.
The Same Cycle in Python
# test_discount.py
import pytest
from discount import calculate_discount
def test_applies_ten_percent_discount():
assert calculate_discount(100, 10) == 90.0
def test_returns_original_price_when_no_discount():
assert calculate_discount(50, 0) == 50.0
def test_raises_when_discount_exceeds_100():
with pytest.raises(ValueError, match="Discount cannot exceed 100%"):
calculate_discount(100, 110)Red. Now make it green:
# discount.py
def calculate_discount(price: float, discount_percent: float) -> float:
if discount_percent > 100:
raise ValueError("Discount cannot exceed 100%")
return price - (price * discount_percent / 100)Green. Refactor:
# discount.py
MAX_DISCOUNT_PERCENT = 100
def calculate_discount(price: float, discount_percent: float) -> float:
"""Calculate the discounted price given a percentage off."""
if discount_percent > MAX_DISCOUNT_PERCENT:
raise ValueError(f"Discount cannot exceed {MAX_DISCOUNT_PERCENT}%")
discount_amount = price * (discount_percent / 100)
return price - discount_amountStill green. The pattern is identical regardless of language.
Common Mistakes and How to Avoid Them
Writing too much in the green phase. The green phase should take 2 minutes, not 20. If you are writing a lot of code to make one test pass, the test is probably too large. Break it down.
Skipping the refactor phase. This is how TDD codebases become messy. The refactor phase is not optional. If you skip it, you accumulate technical debt on a per-test-cycle basis.
Writing tests after the code. This produces tests that confirm the code's current behavior, not tests that specify intended behavior. The gap matters enormously when the code has bugs.
Making the tests too broad. Each test should cover one behavior. If your test has 15 assertions, it is testing too many things at once. Narrower tests give more useful failure messages.
TDD at Scale with End-to-End Testing
The red-green-refactor cycle works at the unit level, but the same discipline applies to higher-level tests. Tools like HelpMeTest extend this approach to full browser-based end-to-end tests. You write what the user should be able to do — in plain English — before you build the feature. The test fails because the feature does not exist. You build the feature, run the test again, and it passes.
The tooling is different but the cycle is identical. Red. Green. Refactor.
Starting Today
If you have never practiced TDD, pick one small feature from your current backlog. Write three tests for it before you open your editor to write any implementation code. Run them, watch them fail, then implement just enough to make them pass.
The first few cycles will feel slow. By the end of the day, you will be writing code faster than you did before — because you will not be spending time debugging things that should have been caught earlier. That is the payoff the cycle delivers.