Introduction
A checkout endpoint charges a customer twice because someone edited applyDiscount on a Friday afternoon and nobody re-ran the edge cases. If a test had failed the moment the change landed, the bug would never have shipped. That’s the concrete problem TDD solves.
Test-Driven Development is a workflow where you write the test before the production code. The cycle is short and repeating: write a failing test, write the smallest amount of code that makes it pass, then refactor while the suite stays green. It’s slower to type and faster to debug — the trade is deliberate.
This guide walks the cycle hands-on: a complete worked example in Python, tooling per language, test doubles, how TDD sits next to BDD, and — just as important — when to skip it.
The Red-Green-Refactor Cycle
1. Red — Write a Failing Test
Start with a test that describes the behavior you want. Run it and watch it fail.
# test_calculator.py
def test_add_two_numbers():
calc = Calculator()
result = calc.add(2, 3)
assert result == 5
$ pytest test_calculator.py
FAILED: Calculator not defined
Why red first? A test that passes without any code proves nothing. Watching it fail confirms the test actually exercises something.
2. Green — Write Minimal Code
Write the simplest code that makes the test pass. Don’t worry about elegance yet.
# calculator.py
class Calculator:
def add(self, a, b):
return a + b # simplest possible implementation
$ pytest test_calculator.py
PASSED
Why minimal? You want the shortest path to green. Premature abstraction obscures whether the test verifies the right thing. Yes, return 5 would also pass — that’s fine, the next test forces real logic.
3. Refactor — Improve the Design
Now that the test passes, clean up: rename variables, extract methods, remove duplication. Run the tests after each change.
# Refactored: still passes, but cleaner
class Calculator:
def add(self, augend, addend):
return augend + addend
Why refactor while green? Tests act as a safety net. If a refactor breaks something, you know within seconds, not after a deploy.
Reading the Red Bar
A good failing test tells you exactly what is missing. FAILED: Calculator not defined says “the class doesn’t exist yet” — a clear next action. A wall of stack trace that takes five minutes to decipher means the test was too big: it tried to verify several behaviors at once. When a red bar surprises you, the usual causes are a typo in the arrange block, a wrong expectation (the test asserts 5 but the spec says 6), or shared state leaking from a previous test. Fix the test, not the output — never adjust an assertion just to make the bar go green.
A Complete Example
Let’s build a ShoppingCart using TDD — each step is one red-green-refactor loop.
Step 1: Empty Cart
def test_empty_cart_total_is_zero():
cart = ShoppingCart()
assert cart.total() == 0
class ShoppingCart:
def total(self):
return 0
Step 2: Add Items
def test_add_item_increases_total():
cart = ShoppingCart()
cart.add(Item("apple", 1.50))
assert cart.total() == 1.50
class ShoppingCart:
def __init__(self):
self.items = []
def add(self, item):
self.items.append(item)
def total(self):
return sum(item.price for item in self.items)
Step 3: Apply Discount
def test_apply_discount():
cart = ShoppingCart()
cart.add(Item("laptop", 1000))
cart.apply_discount("SAVE10")
assert cart.total() == 900
class ShoppingCart:
...
def apply_discount(self, code):
self.discount = 0.10 # hardcoded for now — the next refactor will fix it
def total(self):
subtotal = sum(item.price for item in self.items)
if hasattr(self, 'discount'):
return subtotal * (1 - self.discount)
return subtotal
Step 4: Refactor — Extract Discount Logic
class Discount:
def __init__(self, code, percentage):
self.code = code
self.percentage = percentage
def apply(self, amount):
return amount * (1 - self.percentage)
class ShoppingCart:
def __init__(self):
self.items = []
self.discount = None
def add(self, item):
self.items.append(item)
def apply_discount(self, code):
discounts = {"SAVE10": 0.10, "SAVE20": 0.20}
self.discount = Discount(code, discounts.get(code, 0))
def total(self):
subtotal = sum(item.price for item in self.items)
if self.discount:
return self.discount.apply(subtotal)
return subtotal
Step 5: Edge Case — Invalid Discount Code
def test_invalid_discount_code_is_ignored():
cart = ShoppingCart()
cart.add(Item("mouse", 25))
cart.apply_discount("NOTACODE")
assert cart.total() == 25
Discount(code, 0) already handles it — the test pins the behavior down. Every edge case you can think of costs one cheap test to lock in; that accumulation is what makes the suite valuable a year later.
Notice what the cycle bought you: the Discount class emerged because the tests made a second discount code cheap to support, not because it was designed up front. That’s the design pressure TDD applies — the tests pull the structure out of you. The finished cart and its five-test suite are runnable in the companion repository.
The Three Laws of TDD
Robert C. Martin distilled the discipline into three rules:
- You may not write production code until you have a failing unit test.
- You may not write more of a unit test than is sufficient to fail. (Compilation errors count as failures.)
- You may not write more production code than is sufficient to pass the currently failing test.
In practice nobody applies the laws with total strictness — treat them as a forcing function that keeps the cycle tight, not as a ceremony.
Anatomy of a Good Test
Every unit test has the same skeleton — Arrange, Act, Assert:
def test_total_includes_each_item_once():
# Arrange: build the world the test needs
cart = ShoppingCart()
cart.add(Item("apple", 1.50))
cart.add(Item("apple", 1.50))
# Act: exactly one call
total = cart.total()
# Assert: check observable behavior
assert total == 3.00
- Arrange builds objects, fixtures and stubs. If arrange is longer than act and assert combined, the API under test is probably too chatty — the test just told you something about your design.
- Act is one call, ideally one line. Two actions in a test usually means two tests.
- Assert checks observable behavior: a return value, a state change, an outgoing call. Never assert on internals — that’s how you end up rewriting the suite on every refactor.
For naming, test_<scenario>_<expected> and should_<expected>_when_<condition> both work; consistency matters more than the convention you pick. When a test fails at 2 a.m., its name is the first thing you read — make it a sentence about behavior, not test1.
Tooling for TDD
The cycle is tool-agnostic, but fast feedback changes everything. If pytest takes 30 seconds you’ll skip the red step; if it takes 1.5s you won’t. Pick a watcher mode and keep the loop under ~3 seconds.
| Language | Runner | Watch / fast mode | Notes |
|---|---|---|---|
| Python | pytest 8.x | pytest-watch or pytest -x -q | -x stops at first failure; fixtures handle shared state |
| JavaScript | Jest 29.x | jest --watch | --watchAll re-runs on file change; Vitest 2.x is the faster alternative for Vite projects |
| Java | JUnit 5 (5.10+) | Maven mvn test in IDE or Gradle -t | Gradle’s continuous mode re-runs on save |
| PHP | Pest / PHPUnit 11 | pest --watch | Expressive describe/it syntax borrowed from JS |
# Typical TDD loop in a Python project
$ pytest tests/test_cart.py -x -q
........ [100%]
8 passed in 0.42s
Two settings matter most: run only the test file you’re working on (pytest tests/test_cart.py, jest cart.test.js) and enable bail-on-first-failure so a red bar shows up instantly.
TDD and Test Doubles
TDD assumes your unit test isolates the unit. When the unit talks to a database, a queue or a payment API, you need test doubles — otherwise “unit” tests turn into slow integration tests and the cycle dies.
- Dummy: passed in to fill a parameter, never used.
- Stub: returns canned answers (
gateway.charge()always returnsok). - Fake: a working in-memory implementation (in-memory repo instead of Postgres).
- Mock: a stub that also records calls, letting you assert “was
sendEmailinvoked once with the order id?”.
Use stubs for values flowing in, mocks sparingly for commands flowing out. Over-mocking is the classic TDD failure mode — if every test asserts mock.method.calledWith(...), you’re testing the mock, not the behavior. The API mocking recipe covers the practical patterns for HTTP boundaries.
Test Isolation and Shared State
Nothing kills a TDD practice faster than an order-dependent suite — tests that pass alone but fail together. The cause is almost always shared mutable state: a module-level fixture, a reused database row, a singleton the previous test modified.
- Every test builds its own world. Fresh objects per test beat
setUpsingletons — the extra milliseconds are cheaper than debugging order dependence. - Factory helpers over fixture files. A
make_user(plan="pro")helper stays readable when a 200-line JSON fixture becomes write-only. - Watch global state. Caches, env vars and system time are the usual leaks; inject a clock rather than calling
now()directly. - If tests only fail in sequence, bisect by running pairs until the leaking test shows itself — then fix the leak, don’t mark the victim
skip.
Outside-In vs Inside-Out TDD
Two schools disagree about where the first test goes:
- Inside-out (classicist / Detroit school): start with the innermost unit — the calculator, the cart — and build outward. Fast and keeps tests close to the code, but you risk building elegant units nobody calls.
- Outside-in (mockist / London school): start with a failing acceptance test at the boundary — an HTTP endpoint, a CLI command — then TDD inward. Each layer needs a collaborator that doesn’t exist yet, so you stub it until a deeper cycle builds it. Fowler’s Mocks Aren’t Stubs (linked below) is the classic write-up.
In practice most teams blend them: an outside-in acceptance test at the feature level to keep the work honest, then inside-out cycles inside each class. The acceptance test gives you the red; the unit cycles drive the implementation underneath.
TDD vs BDD
| TDD | BDD | |
|---|---|---|
| Test written as | assert cart.total() == 900 | Given a cart, when I apply SAVE10, then total is 900 |
| Primary goal | Design + regression safety | Shared spec between devs and non-devs |
| Test level | Unit | Acceptance / integration (often) |
| Tools | pytest, Jest, JUnit | Cucumber, Behave, SpecFlow, Jest describe/it |
The same discount behavior in BDD looks like a spec a product owner can read:
Scenario: Discount code applied to cart
Given a cart containing a $1,000 laptop
When I apply the code "SAVE10"
Then the total is $900
They compose: drive the service-level workflow with BDD scenarios, drive the internals with TDD cycles. You don’t pick one — you layer them. The mistake teams make is treating describe/it or Given-When-Then syntax as BDD on its own: renaming a unit test in business vocabulary doesn’t make it a behavior spec. BDD earns its name when the scenario was agreed with someone who doesn’t write code — a PM, a QA, a client.
Benefits of TDD
| Benefit | How TDD Gets You There |
|---|---|
| Confidence | Every feature is backed by a test that proves it works |
| Design pressure | Code must be testable, which tends toward decoupled, modular designs |
| Documentation | Tests are executable examples of how the code should be used |
| Regression safety | Changes are safe because existing tests catch breakages |
| Debugging time | Bugs are caught immediately, not discovered days later |
Industry case studies at Microsoft and IBM (Nagappan et al., 2008) reported defect-density reductions between roughly 40% and 90% on TDD teams — at the cost of 15–35% more development time. Your mileage will vary, but the direction of the trade is consistent.
Common TDD Mistakes
- Testing implementation, not behavior — assert on return values, not internal state. See unit testing.
- Writing too many tests before any code — keep the cycle tight (minutes, not hours).
- Skipping the refactor step — the third step is where clean code improves.
- Testing trivial getters/setters — focus on logic and decisions.
- Not running tests frequently — if you write 50 lines without running tests, you’re not doing TDD.
- A suite nobody trusts — flaky tests get ignored, and ignored tests are worse than none. Quarantine the flake and fix it; don’t let
xfail/skipaccumulate. - Aiming for 100% coverage as a goal — coverage measures what you ran, not what you verified. A suite at 70% covering the risky logic beats a padded 95%.
- One giant test that does everything — when it fails you can’t tell why. One concept per test.
TDD vs Writing Tests After
| Traditional Unit Testing | TDD | |
|---|---|---|
| When tests are written | After code | Before code |
| Test coverage | Often incomplete | Thorough by design |
| Code design influence | Minimal | Major (testability drives design) |
| Debugging effort | Higher | Lower |
Writing tests after isn’t useless — it just removes the design pressure. You end up verifying the implementation you happened to write instead of the behavior you meant to specify. The worst version of this is the end-of-sprint test retrofit: coverage dropped on a dashboard, so someone batch-writes assertions against code already merged. Those tests tend to lock in whatever the code does today, bugs included — they measure the past instead of shaping the next change.
A legitimate test-after case: bug fixes on untested code. Write the reproduction first (it should fail — that’s your red), fix it, and keep the test as a regression guard. That’s TDD applied to a narrow scope, and it works.
When TDD Works Best
Use TDD for:
- Business logic with clear inputs and outputs
- Algorithmic code
- APIs and service boundaries
- Code you expect to change frequently
- Bug fixes — write the failing reproduction test first, then fix
Use caution with:
- UI components (component tests or visual tests fit better)
- Exploratory prototyping and spikes where the expected output isn’t known yet
- Tightly coupled legacy code (refactor to testability first)
- Data migrations — verify the output once, don’t TDD a one-shot script
TDD on the Frontend
Component-level logic TDDs cleanly — validation, formatting, reducers and state transitions all have clear inputs and outputs. What resists TDD is rendering detail: pixel positions, animation timing, layout. For those, component tests (Testing Library) or E2E checks verify what the user actually sees. The practical split: TDD the logic inside the component, test the rendering separately.
TDD on Legacy Code
You rarely get a greenfield. For legacy modules, Michael Feathers’ approach is the practical one: first write characterization tests that pin the current behavior (even if it’s ugly), then TDD the new feature against that safety net. Don’t try to bring the whole module under test — cover only the seam your change touches.
Feathers calls the point where you break a dependency a seam — a place where behavior can change without editing the call site. Constructor injection is the cheapest seam available: pass the collaborator in instead of new-ing it inside the class. Once you can substitute the dependency, the testable surface grows fast.
Bringing TDD to a Team
Adopting TDD is a social problem more than a technical one. What tends to work:
- Start where it hurts. Pick the module that breaks most often and TDD the next change to it. A visible win on a painful file converts skeptics faster than any presentation.
- Pair the first cycles. The awkwardness of test-first fades faster next to someone who has done it than alone with a tutorial.
- Keep the loop fast. Slow feedback kills adoption before the habit forms — if the suite takes ten minutes, people will stop running it before they stop believing in it.
- Review tests like production code. A test with no assertion, or one that re-implements the code it checks, should not pass review.
- Track defect escapes, not coverage. A coverage mandate gets gamed; the count of bugs reaching production tells you whether TDD is actually working.
- Accept the ramp-up. Velocity dips for a few weeks while the habit forms; plan for it instead of treating the dip as proof it doesn’t work.
TDD in Production: A Payment API
On a payment service with fifteen endpoints, the team adopted strict red-green-refactor for the transfer endpoint. One cycle looked like this:
- RED — test: transfer $100 between two accounts of the same user → fails, the route doesn’t exist.
- GREEN — minimal
POST /api/transfersthat performs the transfer → passes. - REFACTOR — extract the balance validation into its own function → passes, no behavior change.
The test suite that grew out of that cycle looks like:
// RED: Test for transfer (fails before the endpoint exists)
describe("POST /api/transfers", () => {
it("should transfer between same user accounts", async () => {
const res = await request(app)
.post("/api/transfers")
.set("Authorization", `Bearer ${userToken}`)
.send({
sourceAccountId: accountA.id,
destinationAccountId: accountB.id,
amount: 100,
currency: "USD"
});
expect(res.status).toBe(201);
expect(res.body.status).toBe("completed");
// Verify updated balances
const src = await getAccount(accountA.id);
const dst = await getAccount(accountB.id);
expect(src.balance).toBe(originalA - 100);
expect(dst.balance).toBe(originalB + 100);
});
it("should reject transfer to foreign account", async () => {
const res = await request(app)
.post("/api/transfers")
.set("Authorization", `Bearer ${userToken}`)
.send({
sourceAccountId: accountA.id,
destinationAccountId: otherUserAccount.id,
amount: 100,
currency: "USD"
});
expect(res.status).toBe(403);
});
it("should reject negative amount", async () => {
const res = await request(app)
.post("/api/transfers")
.set("Authorization", `Bearer ${userToken}`)
.send({
sourceAccountId: accountA.id,
destinationAccountId: accountB.id,
amount: -50,
currency: "USD"
});
expect(res.status).toBe(400);
});
});
The lessons that stuck: RED first — the test defines the expected behavior. GREEN minimal — don’t add extra logic until a test demands it. REFACTOR safe — tests protect the change. One test per behavior, not per method: should reject transfer to foreign account describes behavior, not an implementation detail. And zero flakes — every test is deterministic and needs no external service, so the suite stays trustworthy.
Three months later the suite was about 400 tests running in under two minutes. The transfer endpoint changed hands twice in that period and neither handoff shipped a regression. The one rule that saved them: never commit red. If the suite went red on main, the first response was a revert, and questions came after. That rule alone kept the suite trustworthy — a suite everyone believes is worth more than any metric.
The TDD Workflow, Condensed
- Pick the smallest behavior that’s missing.
- Write one failing test for it — and confirm it fails for the right reason.
- Write the minimal code to pass. Resist polishing.
- Refactor freely while the bar stays green.
- Run the neighboring tests, not only the one you just wrote.
- Commit on green only.
That’s the whole practice — tens of these micro-cycles per hour of work. Everything else in this guide is the discipline that keeps each step honest.
What Works
- Keep tests fast — a slow suite discourages running it; under ~3s per file keeps the loop alive.
- One concept per test — a failure should point to exactly one problem.
- Use descriptive test names — the name should state the scenario and the expected outcome.
- Avoid test interdependence — each test creates its own state.
- Refactor tests too — duplicated setup is a smell; use fixtures and helpers.
- Start where it hurts — apply TDD first to the module that breaks most often, not to a greenfield pet file.
- Pair on the first cycle — the awkwardness of test-first disappears faster with a partner than with a tutorial.
Further Reading
- Test-Driven Development (Martin Fowler) — the canonical essay on the cycle.
- Mocks Aren’t Stubs (Martin Fowler) — the definitive treatment of test doubles.
- Test-Driven Development on the C2 Wiki — original wiki where the practice was documented and debated.
- Nagappan, Maximilien, Bhat & Williams, “Realizing quality improvement through test driven development” (ESEC/FSE 2008) — the Microsoft/IBM defect-reduction study cited above.
- Runnable companion — the ShoppingCart pytest suite (one test per TDD cycle) plus the Jest + supertest transfers suite with wiring stubs.
Frequently Asked Questions
Does TDD slow down development?
Initially yes — expect 15–35% more time writing the feature. It pays back in reduced debugging and safer refactoring; the Microsoft/IBM studies cited above measured large defect reductions against that upfront cost.
What if I don't know what the API should look like yet?
TDD is a design tool — writing the test first helps you discover the API shape. If you're truly exploring, do a quick spike without tests, then rewrite with TDD once the problem is understood.
Should I use TDD for every single function?
No. Focus on code with behavior worth verifying. Simple data objects, configuration and pass-through functions rarely need dedicated tests.
When should you NOT use TDD?
Skip it for exploration (spikes, POCs) where the expected output is unknown, for one-shot scripts and data migrations, and for early UI prototypes. For bug fixes, do the opposite — write the failing reproduction test first, then fix.
Does TDD mean every line needs a unit test?
No. TDD means the test drives the design of what you write; coverage is a side effect, not the goal. Integration paths, wiring and config are better covered by integration or contract tests — the testing strategy guide maps the pyramid.
Related Resources
Software Testing Strategy Guide
A practical guide to building a layered testing strategy with unit, integration, contract, and end-to-end tests that your team will actually maintain.
GuideClean Code Principles: Naming, Functions, Classes
A practical guide to clean code principles: meaningful names, small functions, single responsibility, useful comments, error handling, DRY, and formatting.
RecipeUnit Testing
How to write fast, deterministic unit tests with mocks and assertions in Python, JavaScript, and Java.
RecipeAPI Mocking for Testing
Build reliable tests by mocking external APIs with WireMock, MockServer, and MSW to eliminate flakiness and test edge cases.
GuidePytest in Production Guide
Master pytest for production codebases: advanced fixtures, plugins, custom markers, parametrized tests, parallel execution with pytest-xdist, and CI integration.
DocAPI Testing Strategy Template
A template for planning contract tests, integration tests, and load tests for APIs.