Advanced pytest Patterns
Conftest Architecture
tests/
├── conftest.py # session-scoped fixtures (db, engine, settings override)
├── unit/
│ ├── conftest.py # unit-test-specific fixtures (no db)
│ └── test_validators.py
├── integration/
│ ├── conftest.py # integration fixtures (real db, real redis)
│ └── test_user_flow.py
└── e2e/
├── conftest.py # httpx.AsyncClient, live app
└── test_signup.py
Custom Markers
# conftest.py
import pytest
def pytest_configure(config):
config.addinivalue_line("markers", "slow: marks tests as slow (deselect with -m 'not slow')")
config.addinivalue_line("markers", "integration: marks integration tests requiring db/redis")
config.addinivalue_line("markers", "external: marks tests that call real external APIs")
# Usage
@pytest.mark.slow
@pytest.mark.integration
async def test_report_generation(db, client):
...
Fixture Factories with faker
from faker import Faker
import pytest
fake = Faker()
@pytest.fixture
async def make_user(db):
created = []
async def _make(**kwargs) -> User:
data = {
"email": fake.email(),
"name": fake.name(),
"password": "test_password_123",
**kwargs,
}
user = await user_service.create(db, data)
created.append(user)
return user
yield _make
# cleanup
for user in created:
await db.delete(user)
await db.commit()
Parametrize with Fixtures (pytest-lazy-fixture / indirect)
@pytest.mark.parametrize("role,expected_status", [
("admin", 200),
("viewer", 403),
("anonymous", 401),
])
async def test_admin_endpoint_access(role, expected_status, client, make_user):
if role != "anonymous":
user = await make_user(role=role)
client.force_authenticate(user=user)
response = await client.get("/api/admin/users")
assert response.status_code == expected_status
Parallel Test Execution
# Install pytest-xdist
pip install pytest-xdist
# Run in parallel
pytest -n auto # auto-detect CPU count
pytest -n 4 # 4 workers
pytest -n auto --dist=loadscope # group by module (good for db tests)
Coverage Configuration
[tool.pytest.ini_options]
addopts = ["--cov=app", "--cov-report=term-missing", "--cov-fail-under=80"]
[tool.coverage.run]
branch = true # measure branch coverage, not just line coverage
omit = ["tests/*", "*/migrations/*", "app/main.py"]
[tool.coverage.report]
show_missing = true
skip_covered = false
exclude_lines = [
"pragma: no cover",
"if TYPE_CHECKING:",
"raise NotImplementedError",
"@(abc\\.)?abstractmethod",
]
Snapshot Testing
import pytest
def test_api_response_shape(snapshot, client):
response = client.get("/api/v1/users/1")
assert response.json() == snapshot # generates snapshot on first run, compares on subsequent
# Uses pytest-snapshot or syrupy