Skip to content
Home
TDD Guide: Red-Green-Refactor Cycle & Practical Examples

TDD Guide: Red-Green-Refactor Cycle & Practical Examples

Testing & QA Testing & QA 8 min read 1519 words Beginner ExcellentWiki Editorial Team

Test-Driven Development (TDD) is a software development discipline where you write tests before writing production code. The cycle is iterative and consistent: write a failing test, write the minimum code to make it pass, then refactor while keeping all tests green. Kent Beck rediscovered and formalised this practice in his 2002 book Test-Driven Development: By Example, demonstrating how TDD produces cleaner designs, higher test coverage, and greater confidence in code changes.

Research published in the IEEE Transactions on Software Engineering found that TDD reduces defect density by 40 to 90 percent compared to test-last development, depending on team experience and domain complexity. The ISTQB Foundation Level syllabus includes TDD as a key practice in agile testing, noting that it ensures testability is considered from the start of development.

The TDD Cycle

Red — Write a Failing Test

Write a test that defines the desired behaviour before implementing anything. Run the test to confirm it fails — this proves the test is valid and the feature does not exist yet. The failing test message should clearly indicate what behaviour is expected but missing. A test that passes before any implementation code is written is either testing existing behaviour or contains a false positive assertion.

def test_cart_applies_discount_for_loyal_members():
    cart = ShoppingCart()
    cart.add_item(Item("Widget", price=100.00), quantity=1)
    cart.set_loyalty_status("gold")

    total = cart.calculate_total()

    assert total == 90.00  # 10% gold member discount

def test_cart_no_discount_for_regular_customers():
    cart = ShoppingCart()
    cart.add_item(Item("Widget", price=100.00), quantity=1)

    total = cart.calculate_total()

    assert total == 100.00

These tests will fail because ShoppingCart has no set_loyalty_status method or discount logic.

Green — Make the Test Pass

Write the minimum production code to pass the tests. Do not add features beyond what the tests require. The green phase should take seconds, not minutes. If you cannot make the test pass quickly, either the test is too large or the design needs rethinking.

class ShoppingCart:
    def __init__(self):
        self.items = []
        self.loyalty_status = "standard"

    def add_item(self, item, quantity=1):
        self.items.append((item, quantity))

    def set_loyalty_status(self, status):
        self.loyalty_status = status

    def calculate_total(self):
        subtotal = sum(item.price * qty for item, qty in self.items)
        if self.loyalty_status == "gold":
            return subtotal * 0.9
        return subtotal

Refactor — Improve the Code

With passing tests, improve the design. Extract the discount logic into a strategy pattern, rename variables for clarity, and remove duplication. The test suite provides a safety net that catches mistakes during refactoring.

Benefits of TDD

Design Quality

TDD forces you to consider the interface before implementation. Because you write the test first, you must decide how the code will be called — its public API, parameter types, and return values — before writing the body. This naturally produces loosely coupled, highly cohesive designs that are easier to test and maintain. Kent Beck calls this “the humblest of all practices” because it requires the discipline to write a test before code, but the payoff in design quality is disproportionate to the effort invested. Classes designed through TDD tend to have single responsibilities, explicit dependencies, and clear boundaries between concerns.

Comprehensive Test Coverage

When tests are written first, every line of production code exists because a test required it. Coverage is built-in rather than retrofitted, and tests cover edge cases that test-last approaches often miss. TDD coverage tends to be more meaningful than retrofitted coverage because each test drives specific functionality rather than merely exercising code to satisfy a coverage threshold.

Faster Debugging

When a test fails in TDD, the cause is almost always in the code written since the last test pass. The location of the bug is constrained to a small, recent change, dramatically reducing debugging time.

Confidence to Refactor

Teams with comprehensive TDD test suites refactor aggressively because they trust the tests to catch regressions. Without TDD, refactoring is risky and often avoided until code becomes unmaintainable.

TDD in Practice

Start Simple

Begin with the simplest possible test case — the happy path. Then add edge cases and error conditions one at a time. Each new test drives a small increment of production code. The increment size should be small enough that the green phase takes less than two minutes. If you are writing more than a few lines of production code to make a test pass, the test is probably too large and should be split into smaller tests. Each new test drives a small increment of production code.

Outside-In TDD

For complex features, start with an acceptance-level test that describes the feature end-to-end. Watch it fail, then drill down to unit-level TDD to implement the components. The acceptance test stays red until all unit tests are green, at which point it passes as well. This approach, also called “double-loop TDD,” keeps the team focused on the user-facing behaviour while building robust unit-level coverage underneath.

Handling Dependencies

Use mocks and stubs to isolate the unit under test from slow or unpredictable dependencies. The Mockito framework documentation advises mocking at the boundaries of your system — external APIs, databases, file systems — while testing internal logic through the public API with real objects. A common TDD workflow for dependency-heavy code is to write the test with a mock, implement the production code against the mock interface, then write a separate integration test that verifies the mock behaviour matches the real dependency.

Test Behaviours, Not Methods

Tests should describe what the system should do, not how it does it. A test named test_sort_orders_by_date is better than test_sort_private_method because it describes behaviour rather than implementation. Behavioural tests survive refactoring because they are not tied to specific method names or internal data structures.

Common Pitfalls

Writing tests that are too large makes failures hard to diagnose. Each test should verify a single behaviour. Skipping the refactor step accumulates technical debt that slows future development. Testing trivial code — simple getters and setters — wastes time without adding value. Slow tests break the TDD rhythm; keep unit tests executing in milliseconds.

TDD and Legacy Code

Applying TDD to untested legacy code requires a different approach. Start by writing characterisation tests that capture current behaviour without modifying the code. Then use the “Lifting” technique to extract testable units from monolithic functions. Michael Feathers’s Working Effectively with Legacy Code provides detailed strategies for introducing TDD into legacy codebases safely.

TDD in Practice

Effective TDD requires discipline in the red-green-refactor cycle. Write the test before the implementation code. Watch the test fail (red) to confirm it can detect the absence of the feature. Write the minimum code to make it pass (green). Refactor to improve design without changing behavior. The cycle generates a comprehensive test suite naturally, but it only works if you commit to the discipline — skipping the red step or the refactor step undermines the benefits.

When TDD Works Best

TDD excels for algorithmic code, business logic, and well-defined interfaces. It is less suitable for UI layout, exploratory programming, and prototypes where requirements are unknown. The TDD approach of “test first” can be applied at different granularities: acceptance test-driven development (ATDD) works at the feature level, while classic TDD works at the unit level.

Test Environment Management

Test environments are a common bottleneck in quality assurance. Challenges: environment availability conflicts, configuration drift between environments, data inconsistency, and recreation time. Solutions: use infrastructure-as-code (Terraform, Pulumi, CloudFormation) to provision environments consistently. Containerize test environments with Docker Compose for reproducibility. Use ephemeral environments — spin up for each test run and destroy afterward. Production-like test data is essential: anonymized production data copies, synthetic data generation tools, and data masking for sensitive information. Database migration testing is often overlooked — test that migrations work both forward and backward. Environment monitoring tracks availability and alerts when issues arise.

Exploratory Testing Techniques

Exploratory testing complements automated testing by finding unexpected issues. Session-based test management structures exploratory testing into timed sessions with a charter (mission), notes, and bug reports. Heuristic testing uses rules of thumb to guide exploration: FEW HICCUPS (Functionality, Errors, Workflow, High-volume, Interrupts, Compatibility, Configuration, Usability, Performance, Security). Pair testing — two testers working together — combines different perspectives and catches more issues than solo testing. Charter-based testing defines a mission without detailed test cases, allowing testers to follow their curiosity within bounds.

FAQ

Q: Does TDD slow down development?
A: Initially, yes. Over time, TDD reduces debugging time and prevents regressions, making overall development faster. Studies show TDD teams have lower defect rates with comparable feature delivery speed.

Q: Should I use TDD for all code?
A: TDD is most effective for business logic and critical algorithms. UI code, configuration, and boilerplate benefit less from strict TDD.

Q: What if I cannot figure out how to test something?
A: Difficulty testing often indicates a design problem. Refactor the code to be more testable — extract dependencies, use dependency injection, and separate concerns.

Q: How do I handle TDD with external APIs?
A: Write integration tests that call the real API for confidence, then use mocked responses for the TDD cycle to keep tests fast and deterministic.

Internal Links

Section: Testing & QA 1519 words 8 min read Beginner 756 articles in section Report inaccuracy Back to top