138 lines
4.1 KiB
Markdown
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.
|