Advanced Testing with pytest
Expert pytest patterns — conftest hierarchy, factory fixtures, plugins, property-based testing, async tests, and CI integration at scale.
The Testing & Quality chapter covers pytest basics. This chapter goes deeper into patterns used in large production codebases.
conftest.py Hierarchy
pytest discovers conftest.py files automatically. Fixtures defined in a parent directory are available to all child test directories.
myapp/
├── tests/
│ ├── conftest.py # global fixtures (db, client)
│ ├── unit/
│ │ ├── conftest.py # unit-only fixtures
│ │ └── test_services.py
│ └── integration/
│ ├── conftest.py # integration fixtures (real DB)
│ └── test_api.py
# tests/conftest.py
import pytest
@pytest.fixture(scope="session")
def app_config():
return {"debug": False, "database_url": "sqlite:///:memory:"}
@pytest.fixture
def user_factory():
"""Factory fixture — create users with overrides."""
def _make_user(name="Alice", email=None):
email = email or f"{name.lower()}@test.com"
return {"name": name, "email": email}
return _make_user
# tests/unit/test_services.py
def test_create_user(user_factory):
user = user_factory(name="Bob")
assert user["email"] == "[email protected]"
Fixture Scopes
| Scope | Lifetime | Use Case |
|---|---|---|
function |
Per test (default) | Isolated state |
class |
Per test class | Shared setup within class |
module |
Per file | Expensive one-time setup |
session |
Entire test run | Database containers, app instance |
Autouse Fixtures
Run automatically without being passed as an argument:
@pytest.fixture(autouse=True)
def reset_cache():
cache.clear()
yield
cache.clear()
Use sparingly — hidden dependencies make tests harder to understand.
pytest Plugins
pytest-cov — Coverage
pytest --cov=src/myapp --cov-report=term-missing --cov-fail-under=80
pytest-xdist — Parallel Tests
pip install pytest-xdist
pytest -n auto # use all CPU cores
Note: tests must be independent. Shared state (files, DB) requires isolation or scope="session" coordination.
pytest-mock — Cleaner Mocking
def test_fetch(mocker):
mock_get = mocker.patch("myapp.api.requests.get")
mock_get.return_value.json.return_value = {"ok": True}
assert fetch_status() == {"ok": True}
pytest-asyncio — Async Tests
import pytest
@pytest.mark.asyncio
async def test_async_fetch():
result = await fetch_data("https://example.com")
assert result is not None
Configure default mode in pyproject.toml:
[tool.pytest.ini_options]
asyncio_mode = "auto"
Markers
Organize and selectively run tests:
import pytest
@pytest.mark.slow
def test_full_pipeline():
...
@pytest.mark.integration
def test_database_write():
...
@pytest.mark.skip(reason="Waiting for API v2")
def test_new_endpoint():
...
@pytest.mark.xfail(reason="Known bug #123")
def test_edge_case():
...
Register custom markers in pyproject.toml:
[tool.pytest.ini_options]
markers = [
"slow: marks tests as slow (deselect with '-m \"not slow\"')",
"integration: requires external services",
]
Run selectively:
pytest -m "not slow" # skip slow tests locally
pytest -m integration # run integration only
Property-Based Testing with Hypothesis
Find edge cases automatically by generating inputs:
pip install hypothesis
from hypothesis import given, strategies as st
@given(st.integers(), st.integers())
def test_add_commutative(a, b):
assert add(a, b) == add(b, a)
@given(st.text(min_size=1, max_size=100))
def test_slug_never_empty(s):
result = slugify(s)
assert len(result) > 0
Hypothesis shrinks failing examples to the minimal case that breaks your code — invaluable for parsing and validation logic.
Testing Exceptions
def test_invalid_input():
with pytest.raises(ValueError) as exc_info:
parse_age("not-a-number")
assert "invalid" in str(exc_info.value).lower()
Monkeypatch vs Mock
monkeypatch is pytest’s built-in way to temporarily change attributes:
def test_env_var(monkeypatch):
monkeypatch.setenv("API_KEY", "test-key")
assert get_api_key() == "test-key"
def test_disable_feature(monkeypatch):
monkeypatch.setattr("myapp.config.FEATURE_ENABLED", False)
assert process() == "disabled"
Prefer monkeypatch for simple replacements; use unittest.mock or pytest-mock for complex call verification.
Snapshot Testing
Capture expected output and detect unintended changes:
pip install syrupy
def test_render_html(snapshot):
html = render_user_page({"name": "Alice"})
assert html == snapshot
First run saves the snapshot; subsequent runs compare against it.
Test Organization at Scale
tests/
├── conftest.py
├── factories.py # shared factory functions
├── unit/ # fast, no I/O
│ └── test_*.py
├── integration/ # DB, HTTP, file system
│ └── test_*.py
└── e2e/ # full application flows
└── test_*.py
Pyramid guideline: many unit tests, fewer integration tests, minimal e2e tests.
CI Integration
# .github/workflows/test.yml
name: Tests
on: [push, pull_request]
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: "3.12"
- run: pip install -e ".[dev]"
- run: pytest -m "not integration" --cov=src --cov-fail-under=80
- run: pytest -m integration # optional separate job with services
Debugging Failed Tests
pytest tests/test_api.py::test_create_user -v # single test
pytest --lf # re-run last failures
pytest -x # stop on first failure
pytest --pdb # drop into debugger on failure
Common Pitfalls
- Fixture scope too wide — session-scoped mutable state leaks between tests
- Testing private methods — test public behavior instead
- Over-mocking — mock at boundaries (HTTP, DB), not internal functions
- Flaky tests — avoid time-dependent assertions; use
freezegunfor dates - Slow test suite — parallelize with xdist, mark slow tests, use in-memory DB
Related
- Testing & Quality — unittest, pytest basics, coverage
- FastAPI Testing — TestClient patterns
- DevOps & CI/CD — GitHub Actions pipelines
- Type Hints — mypy in CI
Advanced testing is what separates hobby projects from maintainable production codebases.