Skip to content
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.

Note: This guide follows English-language naming conventions and terminology standards common in international development teams. Examples use English identifiers and comments to maximize compatibility across codebases and tooling.

Test-Driven Development (TDD)

Introduction

Test-Driven Development is a software development process where tests are written before the production code. It follows a short, repeating cycle: write a failing test, write the minimal code to pass it, then refactor while keeping tests green.

The Red-Green-Refactor Cycle

┌─────────┐    ┌─────────┐    ┌─────────┐
│   Red   │ →  │  Green  │ →  │ Refactor│
│  Write  │    │ Minimal │    │ Improve │
│  Failing│    │  Code   │    │  Design │
│  Test   │    │ to Pass │    │         │
└─────────┘    └─────────┘    └─────────┘
      ↑                           │
      └───────────────────────────┘

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 is actually testing 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 is actually verifying the right thing.

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 immediately.

A Complete Example

Let’s build a ShoppingCart using TDD.

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

    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

The Three Laws of TDD

  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.

Benefits of TDD

BenefitHow TDD Delivers
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

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

TDD vs. Unit Testing

Traditional Unit TestingTDD
When tests are writtenAfter codeBefore code
Test coverageOften incompleteThorough by design
Code design influenceMinimalMajor (testability drives design)
Debugging effortHigherLower

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

Use caution with:

  • UI components (use component tests instead)
  • Exploratory prototyping
  • Tightly coupled legacy code (refactor to testability first)

What Works

  • Keep tests fast — a slow test suite discourages running it
  • One concept per test — a test failure should point to exactly one problem
  • Use descriptive test names — the name should explain the scenario and expected outcome
  • Avoid test interdependence — each test should create its own state
  • Refactor tests too — duplicated test setup is a smell; use fixtures and helpers

Frequently Asked Questions

Q: Does TDD slow down development? A: Initially yes, but it pays back in reduced debugging and safer refactoring. Studies show TDD can reduce defect rates by 40-90%.

Q: What if I don’t know what the API should look like yet? A: TDD is a design tool. Writing the test first helps you discover the API shape. If you’re truly exploring, a quick spike is fine — then rewrite with TDD once you understand the problem.

Q: Should I use TDD for every single function? A: No. Focus on code with behavior worth verifying. Simple data transfer objects or configuration often don’t need dedicated unit tests.

How do I get started with this in an existing project?

Start with a small, isolated part of your codebase. Apply the concepts from this guide to one module or service. Measure the impact, then expand to other areas.

What tools do I need?

The tools mentioned throughout this guide are listed in each section. Most are open-source and widely adopted. Check the related resources for setup instructions.

How do I measure success after implementing this?

Define clear metrics before starting: performance benchmarks, error rates, or maintainability indicators. Compare before and after. Iterate based on the data, not on assumptions.

Advanced Topics

Scenario: TDD for Payment API

System: Payment API, 15 endpoints
Goal: Strict TDD (red-green-refactor)

TDD cycle per feature:
  1. RED: Write failing test
     - Test: transfer $100 between accounts
     - Result: FAIL (function does not exist)
  2. GREEN: Write minimum code to pass
     - Implement basic transfer
     - Result: PASS
  3. REFACTOR: Improve code without breaking tests
     - Extract validation to function
     - Result: PASS (no behavior change)

```javascript
// RED: Test for transfer (fails)
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);
  });
});

Lessons:

  • RED first: the test defines expected behavior
  • GREEN minimal: do not add extra logic
  • REFACTOR safe: tests protect the change
  • One test per behavior, not per method
  • Test names describe behavior: “should reject…”
  • 0 flaky: deterministic tests, no external dependencies

### When NOT to use TDD?

Do not use TDD for exploration (spikes, POCs) or when you do not know what you will build. TDD requires knowing the expected behavior. For exploratory UI, prototypes, or data migrations, write code first and tests after. For bugs, write the test that reproduces the bug first (RED), then fix (GREEN).