py4u guide

Real-time Example: TDD in Python from Scratch

Test-Driven Development (TDD) is a software development methodology that flips the traditional "code first, test later" approach on its head. Instead of writing code and then testing it, TDD encourages you to **write tests first**, watch them fail, and then write the minimal code required to make the tests pass. Finally, you refactor the code to improve its quality—all while ensuring tests still pass. TDD offers numerous benefits: it ensures your code is testable, reduces bugs, improves maintainability, and acts as living documentation. But for many developers, TDD can feel abstract until they see it in action. In this blog, we’ll demystify TDD with a **real-time example** in Python. We’ll build a simple calculator application from scratch, following the TDD workflow step-by-step. By the end, you’ll understand how to apply TDD to your own projects.

Table of Contents

  1. What is Test-Driven Development (TDD)?
  2. The TDD Workflow: Red-Green-Refactor
  3. Setting Up Your Environment
  4. Real-Time Example: Building a Calculator
  5. Conclusion
  6. References

What is Test-Driven Development (TDD)?

At its core, TDD is a cycle of three steps: Red, Green, and Refactor.

  • Red: Write a test for a specific feature. Since the feature hasn’t been implemented yet, the test will fail (hence “Red”).
  • Green: Write the minimal code required to make the test pass. Don’t over-engineer—just enough to get the test to “Green.”
  • Refactor: Improve the code’s readability, performance, or structure without changing its behavior. Ensure all tests still pass after refactoring.

This cycle repeats for every new feature or bug fix, ensuring your code is always backed by tests.

The TDD Workflow: Red-Green-Refactor

Let’s break down the workflow with a visual:

  1. Red Phase:

    • Define a small, testable feature (e.g., “a calculator should add two numbers”).
    • Write a test that verifies this feature.
    • Run the test—it will fail (expected, since no code exists yet).
  2. Green Phase:

    • Write the simplest code possible to make the test pass.
    • Run the test again—it should now pass (“Green”).
  3. Refactor Phase:

    • Clean up the code (e.g., remove duplication, improve naming, optimize).
    • Re-run tests to ensure behavior hasn’t changed.

Setting Up Your Environment

To follow along, you’ll need:

  • Python 3.6+ (installed from python.org).
  • A code editor (e.g., VS Code, PyCharm).

Project Structure

Create a new directory for your project (e.g., tdd_calculator) with two files:

  • calculator.py: Where we’ll write the calculator logic.
  • test_calculator.py: Where we’ll write our tests (using Python’s built-in unittest framework).

Real-Time Example: Building a Calculator

We’ll build a calculator with basic operations: addition, subtraction, multiplication, and division. We’ll follow TDD for each operation.

Step 1: Write the First Test (Red Phase)

Let’s start with addition. Our first test will verify that add(2, 3) returns 5.

In test_calculator.py, write:

import unittest
from calculator import add  # We’ll implement `add` later

class TestCalculator(unittest.TestCase):
    def test_add(self):
        result = add(2, 3)
        self.assertEqual(result, 5)  # Expect 2 + 3 = 5

if __name__ == '__main__':
    unittest.main()

Why this fails: We haven’t written the add function yet, so running the test will throw an ImportError.

Run the test:

python test_calculator.py

Output (simplified):

E
======================================================================
ERROR: test_add (test_calculator.TestCalculator)
----------------------------------------------------------------------
ImportError: cannot import name 'add' from 'calculator'

----------------------------------------------------------------------
Ran 1 test in 0.001s

FAILED (errors=1)

This is the Red Phase—the test fails as expected.

Step 2: Write Minimal Code to Pass the Test (Green Phase)

Now, write the minimal code in calculator.py to make test_add pass:

def add(a, b):
    return a + b

Run the test again:

python test_calculator.py

Output:

.
----------------------------------------------------------------------
Ran 1 test in 0.000s

OK

The test passes! We’re in the Green Phase.

Step 3: Refactor (Refactor Phase)

Refactoring improves code quality without changing behavior. Our add function is already simple, so no changes are needed here. But if we had messy code (e.g., redundant variables), we’d clean it up now—and re-run tests to ensure they still pass.

Adding Subtraction

Let’s add subtraction. Repeat the TDD cycle.

Red Phase: Write the Test

In test_calculator.py, add a new test method:

def test_subtract(self):
    result = subtract(5, 3)  # New function: subtract
    self.assertEqual(result, 2)  # Expect 5 - 3 = 2

Run the test—it will fail (no subtract function exists).

Green Phase: Implement Subtraction

In calculator.py, add:

def subtract(a, b):
    return a - b

Update the import in test_calculator.py to include subtract:

from calculator import add, subtract

Run the tests:

python test_calculator.py

Output:

..
----------------------------------------------------------------------
Ran 2 tests in 0.000s

OK

Green phase complete!

Adding Multiplication

Next, multiplication.

Red Phase: Test

Add to TestCalculator:

def test_multiply(self):
    result = multiply(4, 5)  # New function: multiply
    self.assertEqual(result, 20)  # Expect 4 * 5 = 20

Green Phase: Implement

In calculator.py:

def multiply(a, b):
    return a * b

Update the import in test_calculator.py:

from calculator import add, subtract, multiply

Tests pass—green phase done.

Adding Division (and Edge Cases)

Division is trickier because of edge cases (e.g., division by zero). Let’s handle normal division first, then the edge case.

Step 1: Test Normal Division (Red Phase)

Add to TestCalculator:

def test_divide(self):
    result = divide(10, 2)  # New function: divide
    self.assertEqual(result, 5)  # Expect 10 / 2 = 5

Step 2: Implement Division (Green Phase)

In calculator.py:

def divide(a, b):
    return a / b

Update the import:

from calculator import add, subtract, multiply, divide

Tests pass.

Step 3: Test Division by Zero (Red Phase)

What if someone tries to divide by zero? We should raise a ValueError.

Add a test to TestCalculator:

def test_divide_by_zero(self):
    with self.assertRaises(ValueError):
        divide(5, 0)  # Expect ValueError when dividing by 0

Run the test—it will fail because divide(5, 0) currently raises a ZeroDivisionError (Python’s default), not a ValueError.

Step 4: Fix Division by Zero (Green Phase)

Update divide in calculator.py to handle division by zero:

def divide(a, b):
    if b == 0:
        raise ValueError("Cannot divide by zero")
    return a / b

Run the tests again—all pass!

Refactoring: From Functions to a Class

So far, we have separate functions (add, subtract, etc.). As we add more operations, a Calculator class with methods will be cleaner and more maintainable. This is a perfect opportunity to refactor!

Red Phase: Update Tests for a Class

Modify test_calculator.py to use a Calculator class:

import unittest
from calculator import Calculator  # Now we’ll use a class

class TestCalculator(unittest.TestCase):
    def setUp(self):
        self.calc = Calculator()  # Create a Calculator instance for all tests

    def test_add(self):
        result = self.calc.add(2, 3)
        self.assertEqual(result, 5)

    def test_subtract(self):
        result = self.calc.subtract(5, 3)
        self.assertEqual(result, 2)

    def test_multiply(self):
        result = self.calc.multiply(4, 5)
        self.assertEqual(result, 20)

    def test_divide(self):
        result = self.calc.divide(10, 2)
        self.assertEqual(result, 5)

    def test_divide_by_zero(self):
        with self.assertRaises(ValueError):
            self.calc.divide(5, 0)

if __name__ == '__main__':
    unittest.main()

Run the tests—they’ll fail because Calculator doesn’t exist.

Green Phase: Implement the Class

Update calculator.py to a class:

class Calculator:
    def add(self, a, b):
        return a + b

    def subtract(self, a, b):
        return a - b

    def multiply(self, a, b):
        return a * b

    def divide(self, a, b):
        if b == 0:
            raise ValueError("Cannot divide by zero")
        return a / b

Run the tests—all pass!

Refactor Phase: Clean Up

Our class is already clean, but we could add docstrings for clarity (without changing behavior):

class Calculator:
    """A simple calculator with basic arithmetic operations."""

    def add(self, a, b):
        """Return the sum of a and b."""
        return a + b

    def subtract(self, a, b):
        """Return the difference of a and b."""
        return a - b

    def multiply(self, a, b):
        """Return the product of a and b."""
        return a * b

    def divide(self, a, b):
        """Return the quotient of a divided by b.
        
        Raises ValueError if b is zero.
        """
        if b == 0:
            raise ValueError("Cannot divide by zero")
        return a / b

Tests still pass—refactoring complete!

Conclusion

In this example, we built a calculator using TDD. We started with a failing test, wrote minimal code to pass it, and refactored to improve design. TDD ensured every feature was tested, and refactoring kept the code clean.

Key takeaways:

  • TDD forces you to think about requirements before writing code.
  • Tests act as documentation and catch regressions when you refactor.
  • The Red-Green-Refactor cycle keeps development focused and iterative.

Try TDD on your next project—start small, and you’ll quickly see the benefits!

References