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

  1. Fixture scope too wide — session-scoped mutable state leaks between tests
  2. Testing private methods — test public behavior instead
  3. Over-mocking — mock at boundaries (HTTP, DB), not internal functions
  4. Flaky tests — avoid time-dependent assertions; use freezegun for dates
  5. Slow test suite — parallelize with xdist, mark slow tests, use in-memory DB

Advanced testing is what separates hobby projects from maintainable production codebases.