Test-Driven Development (TDD): A Practical Guide
Learn the TDD workflow with real examples in Python and JavaScript. Covers red-green-refactor, when TDD works best, and how to handle common challenges.
What you'll learn
- ✓The red-green-refactor cycle with real coding examples
- ✓How TDD drives better software design through small steps
- ✓When TDD works well and when it creates friction
- ✓Practical techniques for handling tricky TDD scenarios
Prerequisites
- •Experience writing tests (any framework)
- •Comfort with a programming language (Python or JavaScript)
- •Basic understanding of software design principles
Test-driven development is writing the test before the code that makes it pass. That single sentence is the entire methodology. Everything else - red-green-refactor, baby steps, triangulation - is technique for doing it effectively. TDD is not about testing. It is about using tests to drive the design of your code.
The red-green-refactor cycle
Every TDD iteration has three phases.
- Red: Write a test that fails. This defines what you want the code to do.
- Green: Write the minimum code to make the test pass. Do not optimize or generalize.
- Refactor: Clean up the code while keeping all tests green.
Let us build a real example: a password strength validator.
Red: first test
Start with the simplest behavior. A password shorter than 8 characters is weak.
# test_password_validator.py
from password_validator import check_password_strength
def test_short_password_is_weak():
result = check_password_strength("abc")
assert result.strength == "weak"
assert "at least 8 characters" in result.feedback[0]
Run the test. It fails because password_validator does not exist yet.
Green: make it pass
Write the minimum code to make this single test pass.
# password_validator.py
from dataclasses import dataclass
@dataclass
class PasswordResult:
strength: str
feedback: list[str]
def check_password_strength(password: str) -> PasswordResult:
if len(password) < 8:
return PasswordResult(
strength="weak",
feedback=["Password must be at least 8 characters"],
)
return PasswordResult(strength="strong", feedback=[])
The test passes. Notice how crude this implementation is - a password of “aaaaaaaa” would be rated “strong.” That is fine. We will add more tests.
Refactor
There is nothing to refactor yet. The code is simple and clear. Move on.
Red: second test
A password with only lowercase letters should be at least “medium” but not “strong.”
def test_no_uppercase_is_not_strong():
result = check_password_strength("abcdefgh")
assert result.strength != "strong"
assert any("uppercase" in f for f in result.feedback)
This test fails because our current implementation returns “strong” for any password with 8 or more characters.
Green: make it pass
def check_password_strength(password: str) -> PasswordResult:
feedback = []
if len(password) < 8:
feedback.append("Password must be at least 8 characters")
if not any(c.isupper() for c in password):
feedback.append("Password should contain at least one uppercase letter")
if len(feedback) >= 2:
strength = "weak"
elif len(feedback) == 1:
strength = "medium"
else:
strength = "strong"
return PasswordResult(strength=strength, feedback=feedback)
Both tests pass.
Continue the cycle
def test_no_digit_is_not_strong():
result = check_password_strength("Abcdefgh")
assert result.strength != "strong"
assert any("digit" in f for f in result.feedback)
def test_no_special_char_is_not_strong():
result = check_password_strength("Abcdefg1")
assert result.strength != "strong"
assert any("special" in f for f in result.feedback)
def test_strong_password():
result = check_password_strength("Abcdef1!")
assert result.strength == "strong"
assert len(result.feedback) == 0
def test_common_password_is_weak():
result = check_password_strength("Password1!")
assert result.strength == "weak"
assert any("common" in f for f in result.feedback)
Each test drives a new piece of implementation. After all tests pass, the code naturally evolves.
COMMON_PASSWORDS = {"password", "12345678", "qwerty", "letmein"}
def check_password_strength(password: str) -> PasswordResult:
feedback = []
if len(password) < 8:
feedback.append("Password must be at least 8 characters")
if not any(c.isupper() for c in password):
feedback.append("Password should contain at least one uppercase letter")
if not any(c.isdigit() for c in password):
feedback.append("Password should contain at least one digit")
if not any(c in "!@#$%^&*()_+-=[]{}|;:,.<>?" for c in password):
feedback.append("Password should contain at least one special character")
if password.lower() in COMMON_PASSWORDS:
feedback.append("This is a commonly used password")
if len(feedback) >= 2 or password.lower() in COMMON_PASSWORDS:
strength = "weak"
elif len(feedback) == 1:
strength = "medium"
else:
strength = "strong"
return PasswordResult(strength=strength, feedback=feedback)
Refactor: extract responsibilities
Now that we have a complete test suite, we can safely refactor without fear of breaking anything.
from dataclasses import dataclass
COMMON_PASSWORDS = {"password", "12345678", "qwerty", "letmein"}
SPECIAL_CHARS = set("!@#$%^&*()_+-=[]{}|;:,.<>?")
@dataclass
class PasswordResult:
strength: str
feedback: list[str]
def _check_rules(password: str) -> list[str]:
rules = [
(len(password) >= 8, "Password must be at least 8 characters"),
(any(c.isupper() for c in password), "Password should contain at least one uppercase letter"),
(any(c.isdigit() for c in password), "Password should contain at least one digit"),
(any(c in SPECIAL_CHARS for c in password), "Password should contain at least one special character"),
(password.lower() not in COMMON_PASSWORDS, "This is a commonly used password"),
]
return [msg for passed, msg in rules if not passed]
def _classify_strength(feedback: list[str], password: str) -> str:
is_common = password.lower() in COMMON_PASSWORDS
if len(feedback) >= 2 or is_common:
return "weak"
if len(feedback) == 1:
return "medium"
return "strong"
def check_password_strength(password: str) -> PasswordResult:
feedback = _check_rules(password)
strength = _classify_strength(feedback, password)
return PasswordResult(strength=strength, feedback=feedback)
All tests still pass. The code is cleaner. That is the refactor step in action.
TDD with JavaScript
The same workflow applies in any language. Here is a shopping cart example with Vitest.
// cart.test.js
import { describe, test, expect } from "vitest";
import { Cart } from "./cart";
describe("Cart", () => {
test("new cart is empty", () => {
const cart = new Cart();
expect(cart.items).toEqual([]);
expect(cart.total).toBe(0);
});
test("add item to cart", () => {
const cart = new Cart();
cart.add({ id: "widget-1", name: "Widget", price: 9.99, quantity: 1 });
expect(cart.items).toHaveLength(1);
expect(cart.total).toBe(9.99);
});
test("adding same item increases quantity", () => {
const cart = new Cart();
cart.add({ id: "widget-1", name: "Widget", price: 9.99, quantity: 1 });
cart.add({ id: "widget-1", name: "Widget", price: 9.99, quantity: 2 });
expect(cart.items).toHaveLength(1);
expect(cart.items[0].quantity).toBe(3);
expect(cart.total).toBeCloseTo(29.97);
});
test("remove item from cart", () => {
const cart = new Cart();
cart.add({ id: "widget-1", name: "Widget", price: 9.99, quantity: 2 });
cart.remove("widget-1");
expect(cart.items).toHaveLength(0);
expect(cart.total).toBe(0);
});
test("apply percentage discount", () => {
const cart = new Cart();
cart.add({ id: "widget-1", name: "Widget", price: 100, quantity: 1 });
cart.applyDiscount({ type: "percentage", value: 10 });
expect(cart.total).toBe(90);
});
test("discount cannot make total negative", () => {
const cart = new Cart();
cart.add({ id: "widget-1", name: "Widget", price: 5, quantity: 1 });
cart.applyDiscount({ type: "fixed", value: 20 });
expect(cart.total).toBe(0);
});
});
// cart.js - evolved through TDD
export class Cart {
#items = new Map();
#discount = null;
get items() {
return Array.from(this.#items.values());
}
get total() {
const subtotal = this.items.reduce(
(sum, item) => sum + item.price * item.quantity,
0
);
if (!this.#discount) return subtotal;
if (this.#discount.type === "percentage") {
return subtotal * (1 - this.#discount.value / 100);
}
return Math.max(0, subtotal - this.#discount.value);
}
add(item) {
const existing = this.#items.get(item.id);
if (existing) {
existing.quantity += item.quantity;
} else {
this.#items.set(item.id, { ...item });
}
}
remove(itemId) {
this.#items.delete(itemId);
}
applyDiscount(discount) {
this.#discount = discount;
}
}
When TDD works best
TDD is most effective when you are working with well-defined inputs and outputs. Business logic, data transformations, validation rules, algorithms, and parsers are all excellent TDD candidates.
# Great for TDD: pure function with clear inputs/outputs
def calculate_shipping_cost(weight_kg: float, zone: str) -> float:
...
# Great for TDD: data transformation
def csv_row_to_user(row: dict) -> User:
...
# Great for TDD: validation
def validate_email(email: str) -> bool:
...
When TDD creates friction
TDD is less helpful when you are exploring an unknown domain, prototyping a UI, or working with infrastructure code where the interface itself is uncertain.
Exploratory code: When you do not know what the API should look like, writing tests first forces premature commitment. Spike first, then add tests.
UI layout and styling: The “correct” output for a CSS change is visual, not logical. Screenshot testing or visual regression tools are better fits.
Glue code: Code that coordinates between libraries without containing logic (controller methods that just call a service and return the result) does not benefit much from TDD.
Common TDD mistakes
Writing too many tests before any implementation
TDD is one test at a time. Writing ten tests and then implementing everything defeats the purpose. The design feedback comes from the cycle, not from having tests.
Making the green step too large
If you find yourself writing 50 lines of code to make a test pass, the test covers too much. Break it into smaller behaviors.
# Too big: tests the entire order processing flow
def test_place_order():
result = place_order(user, cart, payment_method)
assert result.status == "confirmed"
assert result.payment.charged == True
assert result.inventory.decremented == True
assert result.email.sent == True
# Better: test each behavior separately
def test_order_calculates_total_from_cart():
...
def test_order_charges_payment():
...
def test_order_decrements_inventory():
...
Skipping the refactor step
Red-green without refactor produces code that works but accumulates design debt. After each green, ask: is there duplication? Is the code readable? Are the names clear?
Testing implementation instead of behavior
# Bad: tests how the code works
def test_uses_hashmap_for_lookup():
cache = Cache()
assert isinstance(cache._store, dict)
# Good: tests what the code does
def test_cached_value_is_returned():
cache = Cache()
cache.set("key", "value")
assert cache.get("key") == "value"
The transformation priority premise
When making a test pass, choose the simplest transformation.
- Constant: Return a hardcoded value
- Scalar: Replace constant with a variable
- Gather: Collect into a list/array
- Conditional: Add an if/else
- Iteration: Add a loop
- Recursion: Add self-reference
# Test 1: factorial of 0
def test_factorial_zero():
assert factorial(0) == 1
# Green with constant:
def factorial(n):
return 1
# Test 2: factorial of 1
def test_factorial_one():
assert factorial(1) == 1
# Still passes with constant!
# Test 3: factorial of 2
def test_factorial_two():
assert factorial(2) == 2
# Green with conditional:
def factorial(n):
if n <= 1:
return 1
return n * 1 # Still partially constant
# Test 4: factorial of 5
def test_factorial_five():
assert factorial(5) == 120
# Green with recursion:
def factorial(n):
if n <= 1:
return 1
return n * factorial(n - 1)
Each test forced a more general implementation.
Wrapping Up
TDD is a design technique that produces well-tested code as a side effect. The red-green-refactor cycle forces you to think about behavior before implementation, write only the code you need, and refactor with confidence. It works best for business logic with clear inputs and outputs. Start with one test, make it pass with the simplest code possible, clean up, and repeat. The discipline feels slow at first, but it eliminates the debugging sessions and rewrites that eat far more time in the long run.
Related articles
- Testing TDD Workflow with a Real-World Example
Walk through the red-green-refactor cycle step by step, building a password validator to learn practical test-driven development.
- Testing Snapshot Testing: Patterns and Anti-Patterns
Learn when snapshot tests help, when they hurt, and how to use them effectively in React, API, and configuration testing.
- Python Python Testing with pytest: Fixtures, Marks, and Plugins
Master pytest from fixtures and parametrize to marks, plugins, and CI integration. Write fast, maintainable Python tests with practical examples.
- Python Advanced pytest Patterns for Real-World Projects
Master pytest fixtures, parametrize, monkeypatch, and custom markers to write maintainable, expressive tests for production Python code.