Common Testing Patterns¶
This guide covers common patterns for writing tests that work well with pytest-test-categories, including mocking strategies, fixture patterns, and test organization approaches.
Mocking External Services for Small Tests¶
Small tests must be hermetic - they cannot make network calls or access external services. Here are proven patterns for mocking different types of dependencies.
HTTP Clients with pytest-httpx¶
The pytest-httpx library intercepts HTTP requests made with httpx:
import pytest
import httpx
@pytest.mark.small
def test_api_client_fetches_user(httpx_mock):
"""Mock HTTP response for testing API client behavior."""
httpx_mock.add_response(
url="https://api.example.com/users/1",
json={"id": 1, "name": "Alice", "email": "alice@example.com"},
)
with httpx.Client() as client:
response = client.get("https://api.example.com/users/1")
assert response.json()["name"] == "Alice"
Mocking Different HTTP Methods¶
@pytest.mark.small
def test_api_client_creates_user(httpx_mock):
"""Mock POST request."""
httpx_mock.add_response(
url="https://api.example.com/users",
method="POST",
json={"id": 42, "name": "Bob"},
status_code=201,
)
with httpx.Client() as client:
response = client.post(
"https://api.example.com/users",
json={"name": "Bob", "email": "bob@example.com"},
)
assert response.status_code == 201
assert response.json()["id"] == 42
Mocking Error Responses¶
@pytest.mark.small
def test_api_client_handles_errors(httpx_mock):
"""Mock error response."""
httpx_mock.add_response(
url="https://api.example.com/users/999",
status_code=404,
)
with httpx.Client() as client:
response = client.get("https://api.example.com/users/999")
assert response.status_code == 404
HTTP Clients with responses (for requests library)¶
If you use the requests library, use responses:
import pytest
import responses
import requests
@pytest.mark.small
@responses.activate
def test_fetch_user_profile():
"""Mock requests library HTTP calls."""
responses.add(
responses.GET,
"https://api.example.com/users/1",
json={"id": 1, "name": "Alice"},
status=200,
)
response = requests.get("https://api.example.com/users/1")
assert response.json()["name"] == "Alice"
Database Mocking with Fakes¶
Instead of mocking individual database calls, create a fake implementation of your repository:
Repository Interface¶
# src/repositories.py
from abc import ABC, abstractmethod
from dataclasses import dataclass
@dataclass
class User:
id: int
name: str
email: str
class UserRepository(ABC):
"""Abstract repository interface for user persistence."""
@abstractmethod
def get_by_id(self, user_id: int) -> User | None:
"""Find user by ID."""
@abstractmethod
def save(self, user: User) -> User:
"""Save user and return with assigned ID."""
@abstractmethod
def delete(self, user_id: int) -> bool:
"""Delete user by ID. Returns True if deleted."""
Fake Implementation for Testing¶
# tests/fakes.py
from repositories import User, UserRepository
class FakeUserRepository(UserRepository):
"""In-memory fake repository for testing."""
def __init__(self):
self._users: dict[int, User] = {}
self._next_id = 1
def get_by_id(self, user_id: int) -> User | None:
return self._users.get(user_id)
def save(self, user: User) -> User:
if user.id == 0:
user = User(id=self._next_id, name=user.name, email=user.email)
self._next_id += 1
self._users[user.id] = user
return user
def delete(self, user_id: int) -> bool:
if user_id in self._users:
del self._users[user_id]
return True
return False
Using the Fake in Tests¶
import pytest
from fakes import FakeUserRepository
from repositories import User
@pytest.mark.small
class DescribeUserRepository:
"""Tests for user repository behavior using fake implementation."""
def test_saves_and_retrieves_user(self):
repo = FakeUserRepository()
user = User(id=0, name="Alice", email="alice@example.com")
saved = repo.save(user)
retrieved = repo.get_by_id(saved.id)
assert retrieved is not None
assert retrieved.name == "Alice"
def test_returns_none_for_missing_user(self):
repo = FakeUserRepository()
result = repo.get_by_id(999)
assert result is None
Redis with fakeredis¶
Use fakeredis for Redis testing:
import pytest
import fakeredis
@pytest.mark.small
def test_cache_stores_value():
"""Use fakeredis for in-memory Redis testing."""
redis_client = fakeredis.FakeRedis()
redis_client.set("user:1", "Alice")
result = redis_client.get("user:1")
assert result == b"Alice"
Fixture Patterns by Test Size¶
Small Test Fixtures¶
Small test fixtures should create data in memory with no I/O:
# tests/conftest.py
import pytest
from dataclasses import dataclass
@dataclass
class Product:
id: int
name: str
price: float
@pytest.fixture
def sample_products() -> list[Product]:
"""Provide sample products for testing.
This fixture creates pure Python objects - no I/O required.
Safe for small tests.
"""
return [
Product(id=1, name="Widget", price=9.99),
Product(id=2, name="Gadget", price=19.99),
Product(id=3, name="Tool", price=29.99),
]
@pytest.fixture
def product_repository(sample_products):
"""Provide a pre-populated fake repository.
Uses in-memory fake - safe for small tests.
"""
from fakes import FakeProductRepository
repo = FakeProductRepository()
for product in sample_products:
repo.save(product)
return repo
File Fixtures with pyfakefs (for Small Tests)¶
Use pyfakefs for small tests that need file operations without real I/O:
@pytest.fixture
def csv_data():
"""Sample CSV data as a constant (no I/O needed)."""
return "name,price\nWidget,9.99\nGadget,19.99\n"
@pytest.fixture
def json_data():
"""Sample JSON data as a constant (no I/O needed)."""
import json
return json.dumps([
{"name": "Widget", "price": 9.99},
{"name": "Gadget", "price": 19.99},
])
# For tests that need filesystem semantics, use pyfakefs
@pytest.mark.small
def test_csv_parser(fs, csv_data): # pyfakefs fixture
"""Test CSV parsing with fake filesystem."""
fs.create_file("/data/products.csv", contents=csv_data)
products = parse_csv("/data/products.csv")
assert len(products) == 2
File Fixtures with tmp_path (for Medium Tests)¶
Use tmp_path for medium tests that need real file operations:
@pytest.fixture
def csv_file(tmp_path):
"""Create a sample CSV file for testing.
Uses tmp_path for isolated filesystem access.
Requires @pytest.mark.medium (filesystem access).
"""
csv_path = tmp_path / "data.csv"
csv_path.write_text("name,price\nWidget,9.99\nGadget,19.99\n")
return csv_path
@pytest.fixture
def json_file(tmp_path):
"""Create a sample JSON file for testing."""
import json
json_path = tmp_path / "data.json"
json_path.write_text(json.dumps([
{"name": "Widget", "price": 9.99},
{"name": "Gadget", "price": 19.99},
]))
return json_path
@pytest.fixture
def data_directory(tmp_path, csv_file, json_file):
"""Create a directory with multiple data files.
Composes other fixtures for more complex scenarios.
"""
return {
"root": tmp_path,
"csv": csv_file,
"json": json_file,
}
Medium Test Fixtures¶
Medium test fixtures can access localhost and containers:
# tests/medium/conftest.py
import pytest
import threading
from http.server import HTTPServer, BaseHTTPRequestHandler
@pytest.fixture
def local_http_server():
"""Start a local HTTP server for testing.
Creates a real HTTP server on localhost.
Appropriate for medium tests only.
"""
class SimpleHandler(BaseHTTPRequestHandler):
def do_GET(self):
self.send_response(200)
self.send_header("Content-Type", "application/json")
self.end_headers()
self.wfile.write(b'{"status": "ok"}')
def log_message(self, format, *args):
pass # Suppress logs
server = HTTPServer(("127.0.0.1", 0), SimpleHandler)
port = server.server_address[1]
thread = threading.Thread(target=server.serve_forever)
thread.daemon = True
thread.start()
yield f"http://127.0.0.1:{port}"
server.shutdown()
Testcontainers Fixtures¶
For database integration tests, use testcontainers:
# tests/medium/conftest.py
import pytest
try:
from testcontainers.postgres import PostgresContainer
HAS_TESTCONTAINERS = True
except ImportError:
HAS_TESTCONTAINERS = False
@pytest.fixture
def postgres_container():
"""Start a PostgreSQL container for integration testing.
Requires Docker. Appropriate for medium tests with
allow_external_systems=True marker.
"""
if not HAS_TESTCONTAINERS:
pytest.skip("testcontainers not installed")
with PostgresContainer("postgres:15-alpine") as postgres:
# Initialize schema
import psycopg2
conn = psycopg2.connect(
host=postgres.get_container_host_ip(),
port=postgres.get_exposed_port(5432),
database=postgres.dbname,
user=postgres.username,
password=postgres.password,
)
with conn.cursor() as cur:
cur.execute("""
CREATE TABLE users (
id SERIAL PRIMARY KEY,
name VARCHAR(255) NOT NULL,
email VARCHAR(255) UNIQUE NOT NULL
)
""")
conn.commit()
conn.close()
yield postgres
Usage:
@pytest.mark.skipif(not HAS_TESTCONTAINERS, reason="testcontainers not installed")
@pytest.mark.medium(allow_external_systems=True)
def test_database_integration(postgres_container):
"""Test with real PostgreSQL database."""
import psycopg2
conn = psycopg2.connect(
host=postgres_container.get_container_host_ip(),
port=postgres_container.get_exposed_port(5432),
database=postgres_container.dbname,
user=postgres_container.username,
password=postgres_container.password,
)
with conn.cursor() as cur:
cur.execute("INSERT INTO users (name, email) VALUES (%s, %s) RETURNING id",
("Alice", "alice@example.com"))
user_id = cur.fetchone()[0]
conn.commit()
assert user_id > 0
conn.close()
Test Organization Strategies¶
By Feature¶
Organize tests by feature area:
tests/
users/
test_user_creation.py
test_user_authentication.py
test_user_profile.py
products/
test_product_catalog.py
test_product_search.py
orders/
test_order_creation.py
test_order_fulfillment.py
conftest.py
Mark tests with appropriate sizes within each file:
# tests/users/test_user_creation.py
import pytest
@pytest.mark.small
class DescribeUserCreation:
"""Unit tests for user creation logic."""
def test_creates_user_with_valid_data(self, user_repository):
...
def test_rejects_duplicate_email(self, user_repository):
...
@pytest.mark.medium
class DescribeUserCreationIntegration:
"""Integration tests for user creation with database."""
def test_persists_user_to_database(self, postgres_container):
...
By Test Size¶
Organize tests by size for clear separation:
tests/
small/
test_models.py
test_validation.py
test_utils.py
medium/
test_database.py
test_api_integration.py
large/
test_e2e_workflow.py
conftest.py
Apply markers via directory conftest:
# tests/small/conftest.py
import pytest
def pytest_collection_modifyitems(items):
"""Automatically mark all tests in small/ as small tests."""
for item in items:
if "/small/" in str(item.fspath):
item.add_marker(pytest.mark.small)
Hybrid Approach¶
Combine both approaches:
tests/
unit/ # All small tests by feature
users/
test_creation.py
test_validation.py
products/
test_pricing.py
integration/ # All medium tests by feature
users/
test_database.py
products/
test_search.py
e2e/ # All large tests
test_checkout_flow.py
conftest.py
Parametrization Best Practices¶
Simple Parametrization¶
import pytest
@pytest.mark.small
@pytest.mark.parametrize(
("input_value", "expected_output"),
[
(0, 0),
(1, 1),
(2, 4),
(3, 9),
(10, 100),
],
)
def test_square(input_value, expected_output):
"""Test square function with multiple inputs."""
assert square(input_value) == expected_output
Parametrization with IDs¶
@pytest.mark.small
@pytest.mark.parametrize(
("email", "is_valid"),
[
pytest.param("user@example.com", True, id="valid-simple"),
pytest.param("user+tag@example.com", True, id="valid-with-plus"),
pytest.param("user@sub.example.com", True, id="valid-subdomain"),
pytest.param("invalid", False, id="invalid-no-at"),
pytest.param("user@", False, id="invalid-no-domain"),
pytest.param("@example.com", False, id="invalid-no-local"),
],
)
def test_email_validation(email, is_valid):
"""Test email validation with descriptive IDs."""
assert is_valid_email(email) == is_valid
Combining Fixtures with Parametrization¶
@pytest.fixture
def user_factory():
"""Factory fixture for creating test users."""
def create_user(name="Test", email="test@example.com", role="user"):
return User(id=0, name=name, email=email, role=role)
return create_user
@pytest.mark.small
@pytest.mark.parametrize("role", ["user", "admin", "moderator"])
def test_user_permissions(user_factory, role):
"""Test permissions for different user roles."""
user = user_factory(role=role)
permissions = get_permissions(user)
assert "read" in permissions
Dependency Injection Patterns¶
Constructor Injection¶
Design your classes to accept dependencies:
# src/user_service.py
class UserService:
"""User service with injected dependencies."""
def __init__(
self,
repository: UserRepository,
email_client: EmailClient,
):
self._repository = repository
self._email_client = email_client
def create_user(self, name: str, email: str) -> User:
user = self._repository.save(User(id=0, name=name, email=email))
self._email_client.send_welcome(user.email)
return user
Testing with fakes:
@pytest.mark.small
def test_user_creation_sends_welcome_email(mocker):
"""Test with fake repository and mock email client."""
fake_repo = FakeUserRepository()
mock_email = mocker.Mock()
service = UserService(repository=fake_repo, email_client=mock_email)
user = service.create_user("Alice", "alice@example.com")
assert fake_repo.get_by_id(user.id) is not None
mock_email.send_welcome.assert_called_once_with("alice@example.com")
Protocol-Based Injection¶
Use Python protocols for type-safe dependency injection:
# src/protocols.py
from typing import Protocol
class EmailClient(Protocol):
"""Protocol for email sending."""
def send_welcome(self, email: str) -> None:
"""Send welcome email."""
class UserRepository(Protocol):
"""Protocol for user persistence."""
def save(self, user: User) -> User:
"""Save user."""
def get_by_id(self, user_id: int) -> User | None:
"""Get user by ID."""