Writing Unit Tests and TDD in Python: A Practical Guide

Table of Contents

Introduction

Unit testing and Test-Driven Development (TDD) are essential practices for writing reliable Python code. Unit tests verify that individual components of your program work as expected, while TDD ensures you write tests before the code itself, leading to cleaner, more maintainable solutions. In this post, we'll explore how to write unit tests using Python's unittest module and apply TDD to build a Calculator class, with a practical example and actionable tips.

What Are Unit Tests?

Unit tests are automated checks that validate the behavior of small, isolated pieces of code (e.g., functions or methods). In Python, the built-in unittest module is a standard choice, though frameworks like pytest offer simpler alternatives. Unit tests help catch bugs early, improve code quality, and serve as documentation.

Writing Unit Tests with unittest

Setup and Structure

To write unit tests, create a separate test file (e.g., test_calculator.py) for your code (e.g., calculator.py). Here's a simple Calculator class:

# calculator.py
class Calculator:
    def add(self, a, b):
        return a + b
    def divide(self, a, b):
        if b == 0:
            raise ValueError("Cannot divide by zero")
        return a / b

In test_calculator.py, import unittest and the code to test. Create a test class inheriting from unittest.TestCase and write test methods starting with test_:

# test_calculator.py
import unittest
from calculator import Calculator

class TestCalculator(unittest.TestCase):
    def setUp(self):
        self.calc = Calculator()

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

    def test_add_negative(self):
        result = self.calc.add(-1, -1)
        self.assertEqual(result, -2)

    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(10, 0)

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

Running Tests

Run tests using:

python -m unittest test_calculator.py

The output shows a . for each passing test or an F for failures, with details on errors.

Key Features

  • setUp: Initializes objects before each test.
  • Assertions: Methods like assertEqual, assertRaises, and assertTrue verify outcomes.
  • Test Discovery: Use python -m unittest to automatically find and run tests.

What is Test-Driven Development (TDD)?

TDD is a development practice where you write tests before the code, following a cycle to ensure functionality and maintainability.

The TDD Cycle

  1. Red: Write a failing test for a specific feature.
  2. Green: Write the minimal code to pass the test.
  3. Refactor: Improve the code and tests while keeping all tests passing.

Benefits and Drawbacks

Aspect Benefits Drawbacks
Code Quality Cleaner, modular code; fewer bugs Slower initial development
Testing Comprehensive test coverage Requires discipline to write tests first
Maintenance Easier refactoring; tests as docs May not suit prototyping or exploration

Applying TDD to a Calculator Example

Let’s build the Calculator class using TDD, implementing add and divide methods.

Step-by-Step TDD Process

  • Start with an Empty Class:
# calculator.py
class Calculator:
    pass
  • Write a Failing Test for add (Red):
# test_calculator.py
import unittest
from calculator import Calculator

class TestCalculator(unittest.TestCase):
    def test_add(self):
        calc = Calculator()
        result = calc.add(3, 5)
        self.assertEqual(result, 8)

Run: Fails due to missing add method.

  • Implement add (Green):
# calculator.py
class Calculator:
    def add(self, a, b):
        return a + b

Run: Test passes.

  • Add Test for Negative Numbers:
def test_add_negative(self):
    calc = Calculator()
    result = calc.add(-1, -1)
    self.assertEqual(result, -2)

Run: Passes (no code change needed).

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

Run: Fails due to missing divide.

  • Implement divide (Green):
def divide(self, a, b):
    return a / b

Run: Passes.

  • Test Division by Zero (Red):
def test_divide_by_zero(self):
    with self.assertRaises(ValueError):
        self.calc.divide(10, 0)

Run: Fails (raises ZeroDivisionError).

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

Run: All tests pass.

Final Code

The final calculator.py and test_calculator.py are shown above in the unittest section, built incrementally via TDD.

Best Practices for Unit Testing and TDD

  • One Assertion per Test: Focus on a single behavior.
  • Descriptive Names: Use names like test_add_negative_numbers for clarity.
  • Mocking: Use unittest.mock to isolate dependencies (e.g., APIs):
from unittest.mock import patch

@patch("calculator.requests.get")
def test_fetch_number(self, mock_get):
    mock_get.return_value.json.return_value = {"number": 42}
    calc = Calculator()
    result = calc.fetch_number()
    self.assertEqual(result, 42)
  • Coverage: Use pytest-cov to measure coverage (pytest --cov=calculator).
  • Organize: Store tests in a tests/ directory with consistent naming.

Using pytest as an Alternative

pytest simplifies testing with less boilerplate. Install it (pip install pytest) and write tests like:

# test_calculator_pytest.py
from calculator import Calculator
import pytest

def test_add():
    calc = Calculator()
    assert calc.add(3, 5) == 8

def test_divide_by_zero():
    calc = Calculator()
    with pytest.raises(ValueError):
        calc.divide(10, 0)

Run with:

pytest test_calculator_pytest.py

Why Choose pytest?

  • Concise syntax.
  • Rich ecosystem with plugins.
  • Automatic test discovery.

Conclusion

Unit testing and TDD are powerful tools for building reliable Python code. Using unittest, you can create robust tests, while TDD ensures your code evolves incrementally with confidence. The Calculator example demonstrates how TDD drives clean, testable implementations. For simpler workflows, consider pytest. By adopting these practices, you’ll improve code quality, reduce bugs, and make maintenance easier. Start small, write tests first, and refactor often!