StackPractices
beginner By Mathias Paulenko

Test-Driven Development (TDD) — A Practical Workflow

Learn TDD step by step: write a failing test, make it pass, refactor. Red-Green-Refactor with real examples in Python, JavaScript, and Java.

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

flowchart diagram: Red[

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:

  1. You may not write production code until you have a failing unit test.
  2. You may not write more of a unit test than is sufficient to fail. (Compilation errors count as failures.)
  3. 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.

LanguageRunnerWatch / fast modeNotes
Pythonpytest 8.xpytest-watch or pytest -x -q-x stops at first failure; fixtures handle shared state
JavaScriptJest 29.xjest --watch--watchAll re-runs on file change; Vitest 2.x is the faster alternative for Vite projects
JavaJUnit 5 (5.10+)Maven mvn test in IDE or Gradle -tGradle’s continuous mode re-runs on save
PHPPest / PHPUnit 11pest --watchExpressive 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 returns ok).
  • Fake: a working in-memory implementation (in-memory repo instead of Postgres).
  • Mock: a stub that also records calls, letting you assert “was sendEmail invoked 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 setUp singletons — 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

TDDBDD
Test written asassert cart.total() == 900Given a cart, when I apply SAVE10, then total is 900
Primary goalDesign + regression safetyShared spec between devs and non-devs
Test levelUnitAcceptance / integration (often)
Toolspytest, Jest, JUnitCucumber, 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

BenefitHow TDD Gets You There
ConfidenceEvery feature is backed by a test that proves it works
Design pressureCode must be testable, which tends toward decoupled, modular designs
DocumentationTests are executable examples of how the code should be used
Regression safetyChanges are safe because existing tests catch breakages
Debugging timeBugs 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/skip accumulate.
  • 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 TestingTDD
When tests are writtenAfter codeBefore code
Test coverageOften incompleteThorough by design
Code design influenceMinimalMajor (testability drives design)
Debugging effortHigherLower

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:

  1. RED — test: transfer $100 between two accounts of the same user → fails, the route doesn’t exist.
  2. GREEN — minimal POST /api/transfers that performs the transfer → passes.
  3. 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

  1. Pick the smallest behavior that’s missing.
  2. Write one failing test for it — and confirm it fails for the right reason.
  3. Write the minimal code to pass. Resist polishing.
  4. Refactor freely while the bar stays green.
  5. Run the neighboring tests, not only the one you just wrote.
  6. 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

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.