Site

Testing — Python Unit Testing with pytest

Tutorial 9.0  •  Python / Learn

9.0 What This Teaches

This tutorial covers unit testing Python code with pytest:

9.1 Why Write Tests

Tests verify that code does what you intend, and keep doing it as the codebase evolves. A passing test suite means you can refactor with confidence. Tests also document expected behavior in executable form - more reliable than comments. Python's standard library includes unittest, but pytest is the de-facto choice in the ecosystem: it requires less boilerplate, produces clearer failure messages, and has a rich plugin ecosystem.
# Install pytest in your virtual environment:
# pip install pytest
# Run tests:
# pytest            (discovers all test_*.py files)
# pytest -v         (verbose output)
# pytest test_math.py::test_add  (run one test)

9.2 Test Functions

pytest discovers any function named test_* in files named test_*.py or *_test.py. Use plain assert statements - pytest rewrites them to show helpful failure output:
# test_math_utils.py

def add(a: int, b: int) -> int:
    return a + b

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, 42) == 42
Run with pytest test_math_utils.py. On failure, pytest prints the expression and both values automatically.

9.3 Testing Exceptions

Use pytest.raises as a context manager to assert that code raises a specific exception. The test fails if the exception is not raised:
import pytest

def divide(a: float, b: float) -> float:
    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)

def test_divide_negative():
    assert divide(-6, 2) == -3.0

9.4 Fixtures

A fixture is a function decorated with @pytest.fixture that sets up shared state for tests. pytest injects fixtures by name into test function parameters:
import pytest

class ShoppingCart:
    def __init__(self):
        self.items: list[str] = []
    def add(self, item: str): self.items.append(item)
    def total_items(self) -> int: return len(self.items)

@pytest.fixture
def empty_cart():
    return ShoppingCart()

@pytest.fixture
def loaded_cart():
    cart = ShoppingCart()
    cart.add("apple")
    cart.add("banana")
    return cart

def test_empty_cart(empty_cart):
    assert empty_cart.total_items() == 0

def test_add_item(empty_cart):
    empty_cart.add("cherry")
    assert empty_cart.total_items() == 1

def test_loaded_cart(loaded_cart):
    assert loaded_cart.total_items() == 2

9.5 Parameterized Tests

@pytest.mark.parametrize runs the same test function with multiple sets of arguments, eliminating repetition:
import pytest

def clamp(value: float, lo: float, hi: float) -> float:
    return max(lo, min(hi, value))

@pytest.mark.parametrize("value,lo,hi,expected", [
    (5,  0, 10, 5),    # within range
    (-3, 0, 10, 0),    # below minimum
    (15, 0, 10, 10),   # above maximum
    (0,  0, 10, 0),    # at minimum boundary
    (10, 0, 10, 10),   # at maximum boundary
])
def test_clamp(value, lo, hi, expected):
    assert clamp(value, lo, hi) == expected
pytest names each run with the parameter values, so failures identify exactly which case broke.

9.6 Test Organization

Group related tests in a class prefixed with Test (no inheritance required). Place test files in a tests/ directory:
# tests/test_calculator.py

class TestAddition:
    def test_positive(self): assert 2 + 3 == 5
    def test_negative(self): assert -1 + -1 == -2

class TestDivision:
    def test_normal(self): assert 10 / 2 == 5
    def test_by_zero(self):
        import pytest
        with pytest.raises(ZeroDivisionError):
            _ = 1 / 0
Typical project layout:
myproject/
  src/
    mylib.py
  tests/
    test_mylib.py

9.7 Example - All Together

# Testing - pytest suite for a simple statistics module.

import pytest

def mean(data: list[float]) -> float:
    if not data:
        raise ValueError("mean of empty sequence")
    return sum(data) / len(data)

def median(data: list[float]) -> float:
    if not data:
        raise ValueError("median of empty sequence")
    s = sorted(data)
    n = len(s)
    mid = n // 2
    return s[mid] if n % 2 else (s[mid - 1] + s[mid]) / 2

@pytest.mark.parametrize("data,expected", [
    ([1, 2, 3, 4, 5], 3.0),
    ([2, 4], 3.0),
    ([10], 10.0),
])
def test_mean(data, expected):
    assert mean(data) == expected

@pytest.mark.parametrize("data,expected", [
    ([1, 2, 3], 2),
    ([1, 2, 3, 4], 2.5),
    ([5], 5),
])
def test_median(data, expected):
    assert median(data) == expected

def test_mean_empty():
    with pytest.raises(ValueError, match="empty"):
        mean([])

9.8 Exercise

Exercise
  • Write a is_palindrome(s: str) -> bool function and a corresponding pytest test file with at least five parametrized cases, including empty strings and single characters.
  • Write a Stack class with push, pop, and peek methods. Add a fixture that pre-loads a stack with three values, then test each method using that fixture.
  • Test that pop on an empty stack raises IndexError.

9.9 Common Mistakes

Test file or function not named correctly

pytest only discovers files named test_*.py or *_test.py, and functions named test_*. Naming a test check_add or the file tests.py causes it to be silently ignored.

Using == to compare floats

assert 0.1 + 0.2 == 0.3   # fails! floating-point representation error
import math
assert math.isclose(0.1 + 0.2, 0.3)   # correct
Alternatively, use pytest.approx: assert 0.1 + 0.2 == pytest.approx(0.3)

Shared mutable state between tests

Tests must be independent. If one test modifies a shared object, later tests may see unexpected state. Use fixtures that create a fresh object for each test.

9.10 Key Terms

TermMeaning
pytestThird-party test framework; discovered by convention from file and function names
test functionAny function named test_*; pytest runs it and checks for assertion failures
assertPython built-in that raises AssertionError if the expression is False
pytest.raisesContext manager asserting that a block raises a specific exception
fixture@pytest.fixture function providing reusable setup; injected by parameter name
parametrize@pytest.mark.parametrize runs one test function with multiple argument sets
pytest.approxCompares floats with a default relative tolerance of 1e-6
test discoveryAutomatic search for test files and functions matching naming conventions