Advanced pytest Patterns for Real-World Projects
Master pytest fixtures, parametrize, monkeypatch, and custom markers to write maintainable, expressive tests for production Python code.
What you'll learn
- ✓Writing composable fixtures with dependency injection
- ✓Using parametrize to test many cases without repetition
- ✓Mocking external services with monkeypatch and responses
- ✓Organizing tests with custom markers and conftest.py
Prerequisites
None — this post is self-contained.
pytest is the de facto testing framework for Python. Most tutorials cover the basics — writing functions that start with test_ and using assert. This article goes deeper into the patterns that make tests maintainable, fast, and expressive in production codebases.
Fixtures as Dependency Injection
Fixtures are pytest’s mechanism for setup and teardown. What makes them powerful is that they compose through dependency injection: a fixture can depend on other fixtures, and pytest resolves the dependency graph automatically.
import pytest
from sqlalchemy import create_engine
from sqlalchemy.orm import Session
@pytest.fixture
def engine():
eng = create_engine("sqlite:///:memory:")
Base.metadata.create_all(eng)
yield eng
eng.dispose()
@pytest.fixture
def db_session(engine):
"""Depends on 'engine' fixture automatically."""
with Session(engine) as session:
yield session
session.rollback()
@pytest.fixture
def sample_user(db_session):
"""Depends on 'db_session', which depends on 'engine'."""
user = User(name="Ada", email="ada@example.com")
db_session.add(user)
db_session.flush()
return user
def test_user_creation(sample_user, db_session):
found = db_session.query(User).filter_by(name="Ada").first()
assert found is not None
assert found.email == "ada@example.com"
Fixture Scopes
Control how often a fixture is created with the scope parameter:
@pytest.fixture(scope="session")
def expensive_resource():
"""Created once for the entire test session."""
return load_large_dataset()
@pytest.fixture(scope="module")
def module_db():
"""Created once per test module."""
return create_test_database()
@pytest.fixture # default scope="function"
def fresh_state():
"""Created for every single test."""
return {}
Parametrize — Many Tests, Zero Repetition
@pytest.mark.parametrize generates a separate test for each set of inputs. This eliminates copy-paste test functions that differ only in data.
import pytest
def calculate_discount(price: float, tier: str) -> float:
rates = {"bronze": 0.05, "silver": 0.10, "gold": 0.20}
return price * rates.get(tier, 0)
@pytest.mark.parametrize("price, tier, expected", [
(100.0, "bronze", 5.0),
(100.0, "silver", 10.0),
(100.0, "gold", 20.0),
(200.0, "gold", 40.0),
(50.0, "unknown", 0.0),
])
def test_calculate_discount(price, tier, expected):
assert calculate_discount(price, tier) == expected
Each tuple becomes a separate test case with a clear name in the output:
test_discounts.py::test_calculate_discount[100.0-bronze-5.0] PASSED
test_discounts.py::test_calculate_discount[100.0-silver-10.0] PASSED
...
Parametrize with IDs
For clarity, add explicit IDs:
@pytest.mark.parametrize("input_str, expected", [
pytest.param("hello", "HELLO", id="simple-word"),
pytest.param(" spaces ", "SPACES", id="with-whitespace"),
pytest.param("", "", id="empty-string"),
])
def test_normalize(input_str, expected):
assert input_str.strip().upper() == expected
Monkeypatch — Controlled Mutation
monkeypatch lets you replace attributes, environment variables, and dictionary items for the duration of a test. Changes are automatically reverted after the test.
import os
def get_api_url() -> str:
return os.environ.get("API_URL", "https://api.production.com")
def test_api_url_from_env(monkeypatch):
monkeypatch.setenv("API_URL", "https://api.staging.com")
assert get_api_url() == "https://api.staging.com"
def test_api_url_default():
# Previous monkeypatch was automatically reverted
# This test sees the real environment
assert "production" in get_api_url() or get_api_url() == "https://api.production.com"
Replacing Functions and Methods
class PaymentGateway:
def charge(self, amount: float) -> dict:
# Calls external API in production
...
def test_charge_success(monkeypatch):
def mock_charge(self, amount):
return {"status": "success", "transaction_id": "TX-123"}
monkeypatch.setattr(PaymentGateway, "charge", mock_charge)
gateway = PaymentGateway()
result = gateway.charge(50.0)
assert result["status"] == "success"
Custom Markers
Markers let you tag tests and run subsets selectively.
# conftest.py
import pytest
def pytest_configure(config):
config.addinivalue_line("markers", "slow: marks tests as slow")
config.addinivalue_line("markers", "integration: requires external services")
# test_example.py
import pytest
@pytest.mark.slow
def test_large_dataset_processing():
...
@pytest.mark.integration
def test_database_migration():
...
Run only fast tests:
pytest -m "not slow"
Run only integration tests:
pytest -m integration
conftest.py — Shared Fixtures
Place fixtures in conftest.py to share them across multiple test files without imports. pytest discovers conftest.py automatically.
tests/
conftest.py # Shared fixtures available to all tests
test_users.py
api/
conftest.py # Fixtures for API tests only
test_endpoints.py
models/
conftest.py # Fixtures for model tests only
test_user_model.py
# tests/conftest.py
import pytest
@pytest.fixture
def auth_headers():
return {"Authorization": "Bearer test-token-123"}
@pytest.fixture
def app():
from myapp import create_app
app = create_app(testing=True)
return app
Testing Exceptions
pytest provides clean syntax for asserting that code raises specific exceptions:
import pytest
def divide(a: float, b: float) -> float:
if b == 0:
raise ValueError("Cannot divide by zero")
return a / b
def test_divide_by_zero():
with pytest.raises(ValueError, match="Cannot divide by zero"):
divide(10, 0)
def test_divide_type_error():
with pytest.raises(TypeError):
divide("ten", 2)
Temporary Files and Directories
Use the built-in tmp_path fixture for tests that need filesystem access:
def test_write_config(tmp_path):
config_file = tmp_path / "config.toml"
config_file.write_text('[server]\nhost = "localhost"\nport = 8080\n')
content = config_file.read_text()
assert "localhost" in content
assert config_file.exists()
# tmp_path is cleaned up automatically
Capturing Output
Use the capsys fixture to capture stdout and stderr:
def greet(name: str) -> None:
print(f"Hello, {name}!")
def test_greet_output(capsys):
greet("Ada")
captured = capsys.readouterr()
assert captured.out == "Hello, Ada!\n"
Key Takeaways
Advanced pytest usage revolves around four ideas: composable fixtures for setup, parametrize for data-driven tests, monkeypatch for isolation, and conftest.py for organization. Master these and your test suite becomes both comprehensive and maintainable.
Related articles
- Python Python Testing with pytest: Fixtures, Marks, and Plugins
Master pytest from fixtures and parametrize to marks, plugins, and CI integration. Write fast, maintainable Python tests with practical examples.
- FastAPI Testing FastAPI Apps with TestClient and Pytest Fixtures
Build a robust test suite for FastAPI with TestClient, pytest fixtures, dependency overrides, and async testing patterns.
- FastAPI FastAPI Testing with pytest
Write fast, reliable tests for FastAPI apps using TestClient, pytest fixtures, dependency overrides, and a separate test database.
- Testing Pytest Basics: Writing Your First Python Tests
A practical introduction to pytest — installation, test discovery, the assert statement, parametrize, fixtures, and the command-line flags you will use every day.