Files
python-patterns/patterns/testing.md
T
2026-06-01 21:42:05 +00:00

4.1 KiB

Testing

Use fixtures for reusable resource setup, parametrization for behavior matrices, and explicit boundary seams instead of ad hoc mocking.

Why

Good Python tests optimize for three things at once:

  • local readability
  • cheap variation across inputs and modes
  • reusable setup and cleanup without hiding intent

The mature pattern is not just “use pytest.” It is:

  • model resources with fixtures
  • make fixture lifetime visible with yield when cleanup matters
  • use parametrization when one behavior should hold across several inputs
  • test through boundary seams like transports instead of patching internals blindly

The pattern

  1. Use fixtures for shared setup and resources.
  2. Use yield fixtures when setup and teardown both matter.
  3. Use parametrization when the assertion shape is the same but inputs vary.
  4. Prefer explicit seams over invasive mocking.
  5. Keep the test body focused on behavior, not scaffolding.

When to use

Use fixtures when:

  • multiple tests need the same resource wiring
  • setup or cleanup would otherwise dominate the body
  • the setup is a dependency, not the behavior under test

Use parametrization when:

  • one behavior should hold across several inputs or modes
  • the data varies but the test story stays the same

Use transport or injected seams when:

  • the behavior crosses I/O boundaries
  • you want realistic flow without spinning up the whole world

When not to use

Do not hide essential behavior behind a giant fixture tower.

Do not parametrize cases that deserve different narratives or different assertions.

Do not call fixtures directly like helper functions; if you want a helper, write a helper.

Do not mock deep internals when a cleaner external seam exists.

Preferred shapes

Yield fixture for lifecycle

@pytest.fixture
def resource():
    obj = make_resource()
    yield obj
    obj.close()

This keeps setup and teardown obvious.

Parametrization for behavior matrices

@pytest.mark.parametrize("mode", ["prepend", "append", "importlib"])
def test_import_behavior(mode: str) -> None:
    ...

One behavior, several inputs, no duplicated body.

Boundary seam instead of monkeypatch soup

transport = httpx.MockTransport(handler)
client = httpx.Client(transport=transport)

This is usually cleaner than patching internals in three places.

Counterexamples

Repeated setup in every test

def test_a():
    client = make_client()
    tmpdir = make_tmpdir()
    seed_db()


def test_b():
    client = make_client()
    tmpdir = make_tmpdir()
    seed_db()

The scaffolding overwhelms the behavior.

Fixture tower opacity

If understanding the test requires opening six fixtures before reading one assertion, the abstraction has gone too far.

Calling fixtures directly

Pytest explicitly rejects this because fixtures are injected dependencies, not disguised utility functions.

Source signals

Pytest

  • src/_pytest/fixtures.py:1378-1440 makes the contract explicit twice: calling a fixture directly is an error, and yield fixtures run teardown code after the test outcome.
  • testing/test_threadexception.py:84-91 shows a real yield fixture with post-test cleanup work after the yield.
  • testing/acceptance_test.py:158-169 uses @pytest.mark.parametrize(...) to check one behavior across multiple import modes without cloning the test body.
  • testing/acceptance_test.py:561-574 shows another compact parametrized case where only the example data changes.

HTTPX

  • httpx/_transports/asgi.py:63-83 exposes ASGITransport as an in-process integration seam and even documents raise_app_exceptions=False for testing 500 responses.
  • httpx/_transports/mock.py:15-43 exposes MockTransport as a first-class request/response seam for tests.
  • httpx/_client.py:639-660 accepts transport= directly on Client, which is what makes transport substitution a normal testing path instead of a hack.

Bottom line

A good Python test makes the behavior easy to see and the environment cheap to vary.

Use fixtures for lifetime. Use parametrization for variation. Use explicit seams instead of brittle patching.