pytest — Advanced Implementation Playbook
§1 — Production Configuration
# pytest.ini
[pytest]
testpaths = tests
python_files = test_*.py
python_classes = Test*
python_functions = test_*
addopts = -v --strict-markers --tb=short -q
markers =
slow: marks tests as slow (deselect with '-m "not slow"')
integration: marks integration tests
smoke: marks smoke tests
api: marks API tests
filterwarnings =
error
ignore::DeprecationWarning
# pyproject.toml (alternative)
[tool.pytest.ini_options]
testpaths = ["tests"]
addopts = "-v --strict-markers --tb=short"
markers = [
"slow: marks tests as slow",
"integration: integration tests",
"smoke: smoke tests",
]
[tool.coverage.run]
source = ["src"]
omit = ["tests/*", "*/migrations/*"]
[tool.coverage.report]
fail_under = 80
show_missing = true
§2 — Fixtures (Scoping, Factories, Teardown)
# conftest.py — shared fixtures
import pytest
from sqlalchemy import create_engine
from sqlalchemy.orm import Session
# Session-scoped: created once per test session
@pytest.fixture(scope="session")
def engine():
engine = create_engine("sqlite:///test.db")
Base.metadata.create_all(engine)
yield engine
Base.metadata.drop_all(engine)
# Function-scoped: created per test (default), auto-cleanup
@pytest.fixture
def db_session(engine):
connection = engine.connect()
transaction = connection.begin()
session = Session(bind=connection)
yield session
session.close()
transaction.rollback()
connection.close()
# Factory fixture — create multiple instances
@pytest.fixture
def user_factory(db_session):
created = []
def _create_user(name="Test User", email=None, role="viewer"):
email = email or f"{name.lower().replace(' ', '.')}@test.com"
user = User(name=name, email=email, role=role)
db_session.add(user)
db_session.commit()
created.append(user)
return user
yield _create_user
for user in created:
db_session.delete(user)
db_session.commit()
# Autouse fixture — runs for every test in module
@pytest.fixture(autouse=True)
def reset_cache():
cache.clear()
yield
cache.clear()
# tmp_path for file operations (built-in)
def test_writes_output(tmp_path):
output_file = tmp_path / "result.json"
generate_report(output_file)
assert output_file.exists()
data = json.loads(output_file.read_text())
assert data["status"] == "complete"
§3 — Parameterized Tests
import pytest
# Basic parametrize
@pytest.mark.parametrize("input,expected", [
("hello", "HELLO"),
("world", "WORLD"),
("", ""),
("123", "123"),
])
def test_uppercase(input, expected):
assert input.upper() == expected
# Multiple parameters with IDs
@pytest.mark.parametrize("email,valid", [
("user@test.com", True),
("invalid", False),
("", False),
("user@.com", False),
], ids=["valid_email", "no_at_sign", "empty", "missing_domain"])
def test_validate_email(email, valid):
assert validate_email(email) == valid
# Combine parametrize (cartesian product)
@pytest.mark.parametrize("browser", ["chrome", "firefox", "edge"])
@pytest.mark.parametrize("resolution", ["1920x1080", "1366x768", "375x667"])
def test_responsive_layout(browser, resolution):
assert render_page(browser, resolution).is_valid()
# Indirect parametrize (pass to fixture)
@pytest.fixture
def user(request):
return create_user(role=request.param)
@pytest.mark.parametrize("user", ["admin", "editor", "viewer"], indirect=True)
def test_permissions(user):
assert user.can_view()
§4 — Mocking with pytest-mock
# pip install pytest-mock
def test_send_email(mocker):
mock_smtp = mocker.patch("myapp.email.smtplib.SMTP")
send_email("test@example.com", "Hello", "Body")
mock_smtp.return_value.sendmail.assert_called_once()
def test_api_call(mocker):
mock_get = mocker.patch("myapp.api.requests.get")
mock_get.return_value.json.return_value = {"id": 1, "name": "Alice"}
mock_get.return_value.status_code = 200
user = get_user(1)
assert user["name"] == "Alice"
mock_get.assert_called_once_with("https://api.example.com/users/1")
def test_database_error(mocker):
mocker.patch("myapp.db.session.commit", side_effect=IntegrityError("duplicate"))
with pytest.raises(DuplicateError):
create_user("Alice", "alice@test.com")
# Spy — track calls without replacing
def test_logging(mocker):
spy = mocker.spy(logger, "info")
process_order(order)
spy.assert_called_with("Order processed: %s", order.id)
# Mock environment variables
def test_config(monkeypatch):
monkeypatch.setenv("API_KEY", "test-key-123")
monkeypatch.setenv("DEBUG", "true")
config = load_config()
assert config.api_key == "test-key-123"
assert config.debug is True
§5 — Async Testing
# pip install pytest-asyncio
import pytest
@pytest.mark.asyncio
async def test_async_fetch():
result = await fetch_data("https://api.example.com/data")
assert result["status"] == "ok"
@pytest.mark.asyncio
async def test_async_exception():
with pytest.raises(ConnectionError):
await fetch_data("https://invalid.example.com")
# Async fixtures
@pytest.fixture
async def async_client():
async with AsyncClient(app=app, base_url="http://test") as client:
yield client
@pytest.mark.asyncio
async def test_api_endpoint(async_client):
response = await async_client.get("/api/users")
assert response.status_code == 200
assert len(response.json()) > 0
§6 — Testing Exceptions & Warnings
# Exception testing
def test_division_by_zero():
with pytest.raises(ZeroDivisionError):
1 / 0
def test_error_message():
with pytest.raises(ValueError, match=r".*invalid email.*"):
validate_email("not-an-email")
def test_raises_with_info():
with pytest.raises(PermissionError) as exc_info:
delete_file("/protected/file.txt")
assert "permission denied" in str(exc_info.value).lower()
assert exc_info.value.errno == 13
# Warning testing
def test_deprecation_warning():
with pytest.warns(DeprecationWarning, match="use new_func"):
old_func()
§7 — Markers & Custom Plugins
# Custom marker usage
@pytest.mark.slow
def test_full_data_processing():
result = process_large_dataset()
assert result.row_count > 1_000_000
@pytest.mark.integration
def test_database_connection():
assert db.is_connected()
# Run by marker: pytest -m "not slow"
# Run by marker: pytest -m "smoke and not integration"
# Custom plugin — conftest.py
def pytest_collection_modifyitems(config, items):
"""Auto-mark tests in integration/ directory"""
for item in items:
if "integration" in str(item.fspath):
item.add_marker(pytest.mark.integration)
# Custom report header
def pytest_report_header(config):
return f"Environment: {os.getenv('ENV', 'local')}"
§8 — Class-Based Test Organization
class TestUserService:
@pytest.fixture(autouse=True)
def setup(self, db_session, user_factory):
self.db = db_session
self.create_user = user_factory
self.service = UserService(db_session)
def test_create_user(self):
user = self.service.create("Alice", "alice@test.com")
assert user.id is not None
assert user.name == "Alice"
def test_find_by_email(self):
self.create_user(name="Bob", email="bob@test.com")
user = self.service.find_by_email("bob@test.com")
assert user.name == "Bob"
def test_delete_nonexistent(self):
with pytest.raises(NotFoundError):
self.service.delete(999)
class TestPermissions:
"""Nested class for permission-related tests"""
def test_admin_can_delete(self, user_factory):
admin = user_factory(role="admin")
assert admin.can_delete()
def test_viewer_cannot_delete(self, user_factory):
viewer = user_factory(role="viewer")
assert not viewer.can_delete()
§9 — CI/CD Integration
# GitHub Actions
name: Python Tests
on: [push, pull_request]
jobs:
test:
runs-on: ubuntu-latest
strategy:
matrix:
python-version: ['3.10', '3.11', '3.12']
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with: { python-version: '${{ matrix.python-version }}' }
- name: Install deps
run: pip install -r requirements-test.txt
- name: Run tests
run: pytest --cov=src --cov-report=xml --junitxml=results.xml -v
- name: Upload coverage
uses: codecov/codecov-action@v4
with: { files: coverage.xml }
- name: Upload results
uses: actions/upload-artifact@v4
if: always()
with: { name: test-results-${{ matrix.python-version }}, path: results.xml }
§10 — Debugging Quick-Reference
| Problem | Cause | Fix |
|---|
| Fixture not found | Wrong scope or missing conftest.py | Check conftest.py location, fixture name |
ScopeMismatch | Function fixture depends on session fixture | Match scope: session → module → function |
| Tests interfere | Shared mutable state | Use function-scoped fixtures, autouse cleanup |
| Parametrize fails | Wrong number of params | Ensure tuple count matches parameter names |
| Slow collection | Too many test paths | Set testpaths in pytest.ini |
| Async test hangs | Missing @pytest.mark.asyncio | Add marker or set asyncio_mode = "auto" |
| Coverage wrong | Source path mismatch | Set source in [tool.coverage.run] |
| Import errors | Missing __init__.py or bad path | Add __init__.py or use src layout with --import-mode=importlib |
| Monkeypatch not reverting | Using at module scope | Only use in function-scoped fixtures |
| Marker warnings | Marker not registered | Add to markers in pytest.ini |
§11 — Best Practices Checklist
- ✅ Use fixtures over setup/teardown methods
- ✅ Use
conftest.py for shared fixtures (auto-discovered)
- ✅ Use
tmp_path for file operations (built-in, auto-cleanup)
- ✅ Use
monkeypatch for env vars and attribute patching
- ✅ Use
pytest-mock (mocker fixture) over unittest.mock
- ✅ Use
@pytest.mark.parametrize for data-driven tests
- ✅ Register all custom markers in
pytest.ini
- ✅ Use
--strict-markers to catch typos in marker names
- ✅ Use
pytest-cov for coverage with --cov-fail-under=80
- ✅ Use
pytest-xdist for parallel execution: pytest -n auto
- ✅ Use
--tb=short for concise tracebacks in CI
- ✅ Structure:
tests/unit/, tests/integration/, conftest.py
- ✅ Name files
test_*.py and functions test_*
- ✅ Use factory fixtures for creating test objects
- ✅ Use
pytest.raises(match=...) for precise error checking