pytest Testing Patterns
Purpose
Practical pytest patterns for building a robust test suite: fixture composition, parametrize, test doubles (mocker), async test support, and CI coverage integration. Synthesized from: Testing Strategies, TDD, and Property-Based Testing.
Examples
Project layout:
src/
myapp/
service.py
models.py
tests/
conftest.py ← shared fixtures
unit/
test_service.py
integration/
test_db.py
pyproject.toml ← [tool.pytest.ini_options]
pyproject.toml pytest config:
[tool.pytest.ini_options]
testpaths = ["tests"]
addopts = "-v --tb=short --strict-markers -p no:warnings"
markers = [
"integration: mark as integration test",
"slow: mark as slow test",
]
[tool.coverage.run]
source = ["src"]
omit = ["tests/*"]
[tool.coverage.report]
fail_under = 80Fixture hierarchy (conftest.py):
import pytest
from sqlalchemy.ext.asyncio import create_async_engine, AsyncSession
from myapp.db import Base
# Session-scoped: create once per test session
@pytest.fixture(scope="session")
def event_loop_policy():
return asyncio.DefaultEventLoopPolicy()
# Function-scoped (default): fresh DB per test
@pytest.fixture
async def db_session():
engine = create_async_engine("sqlite+aiosqlite:///:memory:")
async with engine.begin() as conn:
await conn.run_sync(Base.metadata.create_all)
async with AsyncSession(engine) as session:
yield session
await engine.dispose()
# Dependency injection override
@pytest.fixture
def app(db_session):
from myapp.main import app
from myapp.db import get_session
app.dependency_overrides[get_session] = lambda: db_session
yield app
app.dependency_overrides.clear()Parametrize — table-driven tests:
import pytest
@pytest.mark.parametrize("input,expected", [
("hello world", 2),
("", 0),
("one", 1),
("a b c", 3), # multiple spaces
])
def test_word_count(input, expected):
assert word_count(input) == expected
# Parametrize across multiple dimensions
@pytest.mark.parametrize("model", ["gpt-4o", "claude-3-5"])
@pytest.mark.parametrize("temperature", [0.0, 0.7])
def test_generation(model, temperature, mock_client):
...Test doubles with pytest-mock:
def test_service_calls_repository(mocker):
# Patch at the use-site, not the definition site
mock_repo = mocker.patch("myapp.service.UserRepository")
mock_repo.return_value.find_by_email.return_value = User(id=1, email="a@b.com")
svc = UserService()
result = svc.get_by_email("a@b.com")
mock_repo.return_value.find_by_email.assert_called_once_with("a@b.com")
assert result.id == 1Async tests (pytest-asyncio):
import pytest
@pytest.mark.asyncio
async def test_async_endpoint(app):
async with AsyncClient(app=app, base_url="http://test") as client:
resp = await client.get("/health")
assert resp.status_code == 200pytest-asyncio mode config (pyproject.toml):
[tool.pytest.ini_options]
asyncio_mode = "auto" # auto-detect async tests; no @pytest.mark.asyncio neededCoverage in CI (GitHub Actions):
- name: Run tests with coverage
run: pytest --cov=src --cov-report=xml --cov-report=term-missing
- uses: codecov/codecov-action@v4
with:
token: ${{ secrets.CODECOV_TOKEN }}
fail_ci_if_error: trueArchitecture
Tests
├── conftest.py ← shared fixtures (session / module / function scope)
├── unit/ ← fast, isolated, no I/O
│ └── test_*.py
├── integration/ ← real DB or external service
│ └── test_*.py
└── e2e/ ← full stack (optional, slowest)
└── test_*.py
Coverage gate → CI: must be ≥ 80% before merge
Fixture scope hierarchy: session > module > class > function. Use the narrowest scope that permits the test to be deterministic. Session-scoped fixtures (heavy DB setup, loaded models) amortize cost across the test suite.
Marker strategy:
@pytest.mark.integration— skip in fast unit CI run; run in full CI and pre-merge@pytest.mark.slow— skip by default in dev (pytest -m "not slow")@pytest.mark.parametrize— prefer over copy-paste; keep fixture setup outside the parametrized loop