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

138 lines
4.1 KiB
Markdown

# 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
```python
@pytest.fixture
def resource():
obj = make_resource()
yield obj
obj.close()
```
This keeps setup and teardown obvious.
### Parametrization for behavior matrices
```python
@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
```python
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
```python
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.