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
- 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.
Benefits of TDD
| Benefit | How TDD Delivers |
|---|---|
| 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 |
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 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 |
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). Related Resources
Software Testing Strategy Guide
A practical guide to building a layered testing strategy with unit, integration, and end-to-end tests.
GuideClean Code Principles: Writing Maintainable Software
A practical guide to clean code: meaningful names, short functions, DRY, SOLID foundations, and habits that make codebases easier to read and maintain.
GuideSOLID Principles Explained with Examples
Learn the five SOLID principles with practical code examples: Single Responsibility, Open/Closed, Liskov Substitution, Interface Segregation, and Dependency Inversion.
RecipeAPI Mocking for Testing
Build reliable tests by mocking external APIs with WireMock, MockServer, and MSW to eliminate flakiness and test edge cases.
DocUser Story and Acceptance Criteria Template
A user story template that connects user needs to implementation with clear acceptance criteria, definition of done, and INVEST principles.
DocAPI Testing Strategy Template
A template for planning contract tests, integration tests, and load tests for APIs.
DocLoad Test Report Template
A standardized template for documenting load test results and recommendations.