Skip to content
Codeloom
Python

Advanced pytest Patterns for Real-World Projects

Master pytest fixtures, parametrize, monkeypatch, and custom markers to write maintainable, expressive tests for production Python code.

·4 min read · By Codeloom
Intermediate 13 min read

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.