Unit and Integration Testing with Pytest
Unit and Integration Testing with Pytest
After reading this chapter, you will master testing in Python using pytest, write declarative assertions without boilerplate classes, manage test dependencies and teardown with fixtures, parameterize test suites across input matrices with @pytest.mark.parametrize, and isolate side effects using unittest.mock.
Mental model
Unlike legacy frameworks (unittest.TestCase) requiring verbose inheritance hierarchies and specialized assertion methods (self.assertEqual), pytest intercepts Python’s native assert statement via AST rewriting to generate comprehensive failure diffs:
Test Execution Pipeline:
pytest CLI
│
▼ Test Discovery
Searches for test_*.py and *_test.py files
│
▼ Dependency Injection
Resolves requested fixture parameters from fixture tree
│
▼ AST Assertion Rewriting
assert calculated == expected ──▶ On failure: prints detailed variable introspection
│
▼ Teardown
Executes post-yield cleanup in reverse fixture dependency order
Minimal example
Save as test_metrics_engine.py:
# test_metrics_engine.py
import pytest
def calculate_percentile(values: list[float], percentile: float) -> float:
"""Compute the specified percentile (0.0 - 1.0) of a sorted list."""
if not values:
raise ValueError("Cannot calculate percentile of empty sequence")
if not (0.0 <= percentile <= 1.0):
raise ValueError("Percentile must fall between 0.0 and 1.0")
sorted_vals = sorted(values)
k = (len(sorted_vals) - 1) * percentile
f = int(k)
c = f + 1 if f + 1 < len(sorted_vals) else f
d = k - f
return sorted_vals[f] + d * (sorted_vals[c] - sorted_vals[f])
# 1. Parameterized test matrix
@pytest.mark.parametrize(
"percentile,expected",
[
(0.0, 10.0), # Min
(0.5, 30.0), # Median
(1.0, 50.0), # Max
]
)
def test_percentile_calculations(percentile: float, expected: float) -> None:
data = [10.0, 20.0, 30.0, 40.0, 50.0]
result = calculate_percentile(data, percentile)
assert result == pytest.approx(expected, rel=1e-3)
# 2. Testing error boundaries with pytest.raises
def test_percentile_empty_data_raises() -> None:
with pytest.raises(ValueError, match="empty sequence"):
calculate_percentile([], 0.5)
if __name__ == "__main__":
import sys
# Direct invocation runner
pytest.main(["-v", __file__])Run via uv run python test_metrics_engine.py:
============================= test session starts ==============================
...
test_metrics_engine.py::test_percentile_calculations[0.0-10.0] PASSED [ 25%]
test_metrics_engine.py::test_percentile_calculations[0.5-30.0] PASSED [ 50%]
test_metrics_engine.py::test_percentile_calculations[1.0-50.0] PASSED [ 75%]
test_metrics_engine.py::test_percentile_empty_data_raises PASSED [100%]
============================== 4 passed in 0.02s ===============================
Worked examples
Case 1: Fixtures with Setup and Teardown Lifecycles
Fixtures using yield execute setup code before the test, suspend execution, pass the resource to the test, and execute cleanup code after the test completes:
# test_storage_fixture.py
import pytest
import tempfile
import sqlite3
from pathlib import Path
from collections.abc import Generator
@pytest.fixture
def db_conn() -> Generator[sqlite3.Connection, None, None]:
"""Provide a fresh in-memory database with pre-populated schema."""
conn = sqlite3.connect(":memory:")
conn.execute("CREATE TABLE inventory (sku TEXT PRIMARY KEY, qty INT)")
conn.execute("INSERT INTO inventory VALUES ('SKU-100', 42)")
conn.commit()
# Yield control to the test function
yield conn
# Teardown code runs after test finishes
conn.close()
def test_inventory_query(db_conn: sqlite3.Connection) -> None:
cursor = db_conn.execute("SELECT qty FROM inventory WHERE sku = 'SKU-100'")
qty = cursor.fetchone()[0]
assert qty == 42Case 2: Isolating External Dependencies with unittest.mock
When testing code that calls external web services, email servers, or payment gateways, use mock to isolate the unit under test:
# test_auth_client.py
from unittest.mock import patch, MagicMock
class Authenticator:
def check_remote_token(self, token: str) -> bool:
# In production: makes a real network HTTP call to an OAuth provider
raise NotImplementedError("Real network endpoint")
def grant_access(user_token: str, auth: Authenticator) -> str:
if auth.check_remote_token(user_token):
return "ACCESS_GRANTED"
return "ACCESS_DENIED"
def test_grant_access_with_mock() -> None:
# Create a mock authenticator
mock_auth = MagicMock(spec=Authenticator)
mock_auth.check_remote_token.return_value = True
status = grant_access("valid_token_string", mock_auth)
assert status == "ACCESS_GRANTED"
mock_auth.check_remote_token.assert_called_once_with("valid_token_string")
if __name__ == "__main__":
test_grant_access_with_mock()
print("Mock unit test passed successfully!")Run:
uv run python test_auth_client.pyOutput:
Mock unit test passed successfully!
Pitfalls
Pitfall 2: Testing Implementation Details Instead of Contracts
Tests that assert every internal private method call (mock_obj._internal_step.assert_called()) become brittle and break upon simple refactoring. Test observable inputs and outputs.
Exercises
- Write a parametrized test suite for an IP address validator testing 5 valid IPs and 5 invalid IPs.
- Create a temporary directory fixture using
tempfile.TemporaryDirectorythat creates a test file and verifies deletion on test completion. - Use
unittest.mock.patch("time.time")to test a rate limiter with simulated deterministically advancing time. - Use
pytest -kandpytest -mto organize tests using custom markers (@pytest.mark.slow,@pytest.mark.integration).
Further reading
- Pytest Documentation: Fixtures, Marks, and Parametrization.
- Python Standard Library:
unittest.mockdocumentation. - Brian Okken: Python Testing with pytest, Second Edition.