Testing Your Python Code

Testing Your Python Code

Testing is the practice of writing code that verifies your code works correctly. Professional Python developers write tests for everything they build. Python's testing ecosystem — led by pytest — makes testing straightforward and even enjoyable.

Why This Chapter Matters

Tests catch bugs before users do. They let you refactor and add features with confidence. They also serve as living documentation of what your code is supposed to do.

  • tests prevent regressions when you change existing code
  • tests make debugging faster by isolating the broken component
  • tests document expected behavior in an executable way
  • CI/CD pipelines run tests automatically before deploying

Types of Tests

TypeWhat It TestsSpeed
Unit testA single function in isolationVery fast
Integration testMultiple components working togetherModerate
End-to-end testA full user flowSlower

Start with unit tests. They give the most value for the least effort.

Using the Built-in assert

The simplest way to test in Python:

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

assert add(2, 3) == 5
assert add(0, 0) == 0
assert add(-1, 1) == 0
print("All tests passed!")

Assertions raise AssertionError if the condition is False. This is fine for learning but pytest is better for real projects.

pytest — The Standard Testing Framework

Installing pytest

pip install pytest

Your First pytest Test

Create a file named test_math.py:

# The code being tested
def add(a, b):
    return a + b

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

# Tests — functions starting with test_
def test_add_positive():
    assert add(2, 3) == 5

def test_add_negative():
    assert add(-1, -1) == -2

def test_add_zero():
    assert add(0, 10) == 10

def test_subtract():
    assert subtract(10, 4) == 6

Running pytest

pytest               # run all tests
pytest test_math.py  # run specific file
pytest -v            # verbose output
pytest -k "add"      # run only tests matching "add"

Test Output

============================= test session starts ============================
collected 4 items

test_math.py::test_add_positive PASSED
test_math.py::test_add_negative PASSED
test_math.py::test_add_zero PASSED
test_math.py::test_subtract PASSED

============================== 4 passed in 0.12s =============================

Organizing Tests

Keep tests in a tests/ directory separate from your source code:

project/
├── src/
│   ├── math_utils.py
│   └── string_utils.py
├── tests/
│   ├── test_math_utils.py
│   └── test_string_utils.py
├── requirements.txt
└── README.md

Testing for Exceptions

Test that code raises the expected exceptions:

import pytest

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

def test_divide_normal():
    assert divide(10, 2) == 5.0

def test_divide_by_zero():
    with pytest.raises(ValueError, match="Cannot divide by zero"):
        divide(10, 0)

The Arrange–Act–Assert (AAA) Pattern

Structure every test with three clear phases:

def test_user_score_after_bonus():
    # Arrange — set up the data
    initial_score = 80
    bonus = 10

    # Act — call the code being tested
    final_score = initial_score + bonus

    # Assert — verify the result
    assert final_score == 90

Fixtures — Reusable Setup

Fixtures provide shared setup and teardown for multiple tests:

import pytest

class BankAccount:
    def __init__(self, balance=0):
        self.balance = balance
    def deposit(self, amount):
        self.balance += amount
    def withdraw(self, amount):
        if amount > self.balance:
            raise ValueError("Insufficient funds")
        self.balance -= amount

@pytest.fixture
def account():
    """Create a fresh account with 100 balance for each test."""
    return BankAccount(balance=100)

def test_deposit(account):
    account.deposit(50)
    assert account.balance == 150

def test_withdraw(account):
    account.withdraw(30)
    assert account.balance == 70

def test_withdraw_insufficient(account):
    with pytest.raises(ValueError):
        account.withdraw(200)

The account fixture runs before each test that requests it.

Parametrize — Test Multiple Cases

import pytest

def celsius_to_fahrenheit(c):
    return (c * 9/5) + 32

@pytest.mark.parametrize("celsius, expected", [
    (0, 32),
    (100, 212),
    (-40, -40),
    (37, 98.6),
])
def test_conversion(celsius, expected):
    assert celsius_to_fahrenheit(celsius) == pytest.approx(expected, rel=1e-3)

This runs four test cases from a single test function.

Mocking — Replace Real Dependencies

Mocking replaces a real dependency (like a database, API call, or file) with a controlled fake.

from unittest.mock import patch, MagicMock

def get_user_from_api(user_id):
    import requests
    response = requests.get(f"https://api.example.com/users/{user_id}")
    return response.json()

def test_get_user_from_api():
    mock_response = MagicMock()
    mock_response.json.return_value = {"id": 1, "name": "Asha"}

    with patch("requests.get", return_value=mock_response):
        result = get_user_from_api(1)

    assert result["name"] == "Asha"

Code Coverage

Measure how much of your code is covered by tests:

pip install pytest-cov
pytest --cov=src --cov-report=html

Open htmlcov/index.html to see which lines are covered.

Aim for high coverage of critical business logic. 100% coverage is not always practical or useful.

Writing Testable Code

Code that is easy to test:

  • Pure functions: Same input → same output, no side effects
  • Small, focused functions: Each does one thing
  • Dependency injection: Pass dependencies in instead of creating them internally
# Hard to test — reads from file internally
def process_data():
    with open("data.txt") as f:
        return [int(line) for line in f]

# Easy to test — accepts data as parameter
def process_data(lines):
    return [int(line) for line in lines]

def test_process_data():
    result = process_data(["1", "2", "3"])
    assert result == [1, 2, 3]

Common Mistakes

  • writing tests after the code is "done" (write them earlier)
  • testing implementation details instead of behavior
  • one giant test with many assertions (prefer many small tests)
  • not testing edge cases: empty input, zero, None, max values
  • not running tests frequently (tests only help if you run them)

Mini Exercises

  1. Write a function is_palindrome(word) and three pytest tests for it.
  2. Write a function safe_divide(a, b) and test both the normal case and division by zero.
  3. Create a fixture that provides a sample list of numbers for use in multiple tests.
  4. Use @pytest.mark.parametrize to test a temperature converter with 5 different values.
  5. Mock the random.randint function to make a test for a dice-roll function deterministic.

Review Questions

  1. What is the difference between a unit test and an integration test?
  2. What is the Arrange–Act–Assert pattern?
  3. What is a pytest fixture and when would you use one?
  4. Why is mocking useful in tests?
  5. What is code coverage and why is 100% not always the goal?

Reference Checklist

  • I can write and run tests with pytest
  • I understand the Arrange–Act–Assert pattern
  • I can test for exceptions with pytest.raises()
  • I can use fixtures for reusable test setup
  • I can parametrize tests to cover multiple input cases
  • I know the basics of mocking with unittest.mock