| name | python-pytest |
| description | Write and structure pytest tests to this project's conventions. Use when authoring tests, parametrizing cases, handling warnings, deciding test coverage, or laying out the tests directory. |
Python pytest Conventions
Standards for authoring tests and laying out the test suite, which uses
pytest.
Writing tests
- Use test functions only — never test classes.
- Do most test setup with fixtures and parameters so the code under test is at, or near,
the beginning of the test function.
- Compose fixtures from other fixtures rather than mutating fixture values, put shared
fixtures in
conftest.py, give each fixture a single purpose, and restore any global
state you mutate after the yield.
- Use explicit
pytest.param(...) instances in parametrized tests, each with a
meaningful id. Param ids can contain spaces and should be written in a human-readable
way.
- Don't parametrize a test that has only one case just to attach an id — keep it a plain
test function.
- When a parametrized case needs more than about three values, wrap them in a small
dataclass passed as one param instead of a long tuple.
- Don't use docstrings or comments in tests. Convey the test's purpose through meaningful
function names, variable names, and param ids.
import pytest
@pytest.mark.parametrize(
("celsius", "expected_fahrenheit"),
[
pytest.param(0, 32, id="freezing point of water"),
pytest.param(100, 212, id="boiling point of water"),
pytest.param(-40, -40, id="scales cross over"),
],
)
def test_celsius_to_fahrenheit(celsius, expected_fahrenheit):
assert celsius_to_fahrenheit(celsius) == expected_fahrenheit
Test design
- Mock at the boundary of services you don't own — outbound HTTP, third-party
APIs; use real implementations for all internal code, including your own thin wrappers
(mock the external call inside the wrapper, not the wrapper itself).
The database is the exception: use a real instance (see External services).
- For mocking use pytest-mock's
mocker, not monkeypatch or unittest.mock.
Patch the name in the namespace under test: under_test.fn when it does
from other import fn, or other.fn when it does import other; pass
autospec=True (or spec=) so signatures stay honest.
- One behaviour per test: assert as many facets of that single result as you need, but
don't bundle unrelated behaviours into one test.
- Derive cases from the behavioural contract: parametrize over distinct observable
outcomes, collapse input combinations that behave identically, and assert the whole
result.
- Don't re-assert what the type system, framework, or DB constraint already guarantees.
- Test through the public API — it's the stable contract, whereas private
(underscore-prefixed) functions are implementation details you should be free to
refactor without touching tests. Don't import a private function to test it; cover it
indirectly through its public callers (preferred), promote it to public if it
genuinely needs direct testing, or drop the test if it adds no meaningful coverage.
- Don't put control flow (
if/for/try) in a test, and don't assert on log output
or third-party library behaviour — test your code, not its dependencies'.
- Use a raw string for
pytest.raises(match=...) patterns that contain regex
metacharacters.
- Make test data deterministic: freeze time (
freezegun/time-machine) for any
now()/expiry logic, and use fixed UUIDs (uuid.UUID(int=N, version=4)) — never the
real clock or random UUIDs.
External services in tests
How the external boundaries above are mocked (or not) in practice.
Postgres — a real container via testcontainers
- Postgres is not mocked. A real, ephemeral Postgres runs via
testcontainers
(PostgresContainer), session-scoped.
- Consequence: DB-backed (integration) tests require Docker to be running; pure unit
tests don't.
Warnings
- Warnings are treated as errors in tests.
- Warnings emitted from code in this repo must be fixed at the source, not silenced.
- Warnings emitted from third-party packages may be ignored, using the most specific
ignore practical.
Coverage philosophy
- Aim for high branch coverage, but hold it in tension with test bloat — a goal, not an
absolute. Every line of test code has a maintenance cost, so don't add tests that don't
meaningfully exercise behaviour.
- Watch the ratio of test code to source code. Past roughly 2:1 you're usually paying to
maintain a lot of test code for diminishing returns — exercising ever-smaller slivers
of source. When closing the last uncovered lines would push the ratio that high, prefer
leaving them uncovered (with a justified
# pragma: no cover where apt) over bloating
the suite.
- Favour higher-level tests near public entry points (e.g. an API endpoint or CLI
command) over many small unit tests: they cover more code per test, cost less to
maintain when internals change, and keep the ratio down.
- Run coverage with
--cov-branch. Cover new branches and error paths by default, but
don't ratchet toward 100% at any cost: a deliberately uncovered path is fine when a test
would cost more to maintain than it is worth.
- When a change grows the test suite, sanity-check the ratio with the bundled
scripts/source_size.py: it reports src/ vs tests/ size per directory and, with
--compare-to <ref>, the delta that change adds.
Test directory layout
/tests/ mirrors the /src/ directory structure and stays in sync with it.
- Test modules are (in most cases) named after the src module they test, with a
test_
prefix. Rarer tests covering functionality spanning several src modules are the
exception.
- Don't create sub-packages in the test directories — no
__init__.py files (a
conftest.py is fine). A consequence of this strategy: no duplicate module (.py)
file names are allowed anywhere in the repo, with the exception of __init__.py and
conftest.py files, since pytest can't support duplicate test file names without
sub-packages.
Running tests
These are reference commands — run them when it suits your workflow:
uv run pytest tests/test_module.py
uv run pytest -n logical --color=no
uv run pytest tests/test_module.py::test_name