9.0 What This Teaches
- Why write tests and the test-first mindset
- Installing and running pytest
- Writing test functions and using
assert - Testing exceptions with
pytest.raises - Fixtures for shared setup
- Parameterized tests with
@pytest.mark.parametrize
9.1 Why Write Tests
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
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
pytest test_math_utils.py. On failure, pytest prints the
expression and both values automatically.
9.3 Testing Exceptions
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
@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
9.6 Test Organization
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
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) -> boolfunction and a corresponding pytest test file with at least five parametrized cases, including empty strings and single characters. - Write a
Stackclass withpush,pop, andpeekmethods. Add a fixture that pre-loads a stack with three values, then test each method using that fixture. - Test that
popon an empty stack raisesIndexError.
9.9 Common Mistakes
Test file or function not named correctly
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
pytest.approx:
assert 0.1 + 0.2 == pytest.approx(0.3)
Shared mutable state between tests
9.10 Key Terms
| Term | Meaning |
|---|---|
| pytest | Third-party test framework; discovered by convention from file and function names |
| test function | Any function named test_*; pytest runs it and checks for assertion failures |
| assert | Python built-in that raises AssertionError if the expression is False |
| pytest.raises | Context 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.approx | Compares floats with a default relative tolerance of 1e-6 |
| test discovery | Automatic search for test files and functions matching naming conventions |