Python testing defaults
Personal, agent-maintained Python projects. Split out of python-conventions on 2026-08-31, which
keeps the design and style defaults; this skill owns the test suite. Nothing here is tool config —
pytest's own configuration lives with the repo's quality tooling, not here.
Each entry says whether it's overriding your own default instinct or just confirming one. A
capable model already parametrizes value matrices and reaches for tmp_path unprompted. This skill
exists for the places a model left alone drifts — inlining the same arrange block into every test
rather than promoting it to a fixture, and reaching for a mock where the suite could own the real
thing.
Testing conventions
- Snippet:
references/snippets/testing.py
- Fixtures first, always. Any setup a test needs — a tmp tree, a fake
HOME, a stubbed c.run, a
constructed object, a monkeypatched env — is a pytest fixture (in conftest.py once two files
want it), not lines hand-rolled at the top of each test body. Two reasons, and the second is the
bigger one: it removes the mechanical duplication, and it surfaces when the suite is doing the
same thing three different ways — three hand-rolled versions of "make a fake repo" hide in three
test bodies indefinitely; three fixtures named fake_repo, tmp_repo, and repo_dir sit next to
each other in conftest.py and get merged. Reach for the built-ins (tmp_path, monkeypatch,
capsys, caplog) before writing a helper that reimplements one. A helper function is the
fallback only for setup that needs per-call arguments a fixture can't take — and even then, a
fixture returning a factory (make_repo(name)) usually fits.
- A fake home is two variables, and a suite that runs on Windows finds three more traps on its first
run there.
expanduser reads HOME on POSIX and USERPROFILE on Windows, so a fixture setting
only the first writes into the real profile once per test. Write a path into a TOML or JSON
fixture as path.as_posix() — in a TOML basic string a backslash opens an escape, and C:\Users
is an invalid \U. Pass encoding="utf-8" to every read_text/write_text, in tests as well as
code: the platform default is a code page there, and a config with an em dash in a comment comes
back as mojibake or a decode error. Key fake-runner tables and compare path lists through
as_posix() too, since Path("/repo") renders as \repo. And pin any platform seam
(WINDOWS = os.name == "nt" in the module under test) to the arm the fixtures were written for,
because a test written on Linux reads the real platform otherwise. Measured 2026-09-05 on a suite
that had never run on Windows: 136 of 557 red on the first run, every one of them one of these
five, none of them in the code under test.
- Fixture scope: narrowest that stays correct. For the module-singleton pattern in
python-conventions — construct the expensive object at module/session scope, but reset its
mutable state via a function-scoped fixture. A monkeypatch inside a broad-scoped fixture stays
live for the whole scope, not just one test — a real, silent cross-test leak source.
- DAMP vs. DRY — a different axis from
python-conventions' production-code DRY decision, not a
re-derivation of it: setup mechanics (fixtures/helpers, the how) stay DRY; the scenario a test
verifies (the what) stays explicit and readable top-to-bottom in that test. parametrize is the
sanctioned everyday tool for a real input→expected matrix, and is more explicit than N
copy-pasted bodies, because the varying values are isolated from the fixed logic — attach ids
once values stop being self-explanatory. The line: if adding a case means adding a value,
parametrize; if it means changing the test's logic (a branch, a different setup, a different
assertion), write a new test. What's actually warned against is collapsing genuinely different
scenarios into one branching mega-test, or hiding the scenario inside a helper whose name doesn't
say what it asserts.
- Model default: mostly confirms, overrides in one direction. A model parametrizes value
matrices unprompted, and that's right. What it does not reliably do is promote setup to fixtures
— left alone it inlines the same three-line arrange block into every test it writes, which is the
"same thing three ways" failure above. The other narrow override:
python-conventions' modularity
abstraction instinct can leak into folding scenarios that differ in logic into one
parametrized-with-branches test, or into a check_* helper that owns the assertion.
- Never run a code-mutating command as part of a test's exercised behavior unless the test's actual
subject is that mutation. A fix/format/autocorrect command run before the assertion silently masks
the exact defect a check-only equivalent would have caught. Confirmed live 2026-08-23 in
scaffoldapy: an e2e test ran inv quality.precommit (fixes formatting, then checks) against a
freshly generated repo — real CI runs the check-only inv quality.check with no such gate, so a
dprint markdown-wrapping bug in the generated README.md/SKILL.md passed this test while
failing every generated repo's actual first CI run. Prefer the check-only/dry-run form of a
command in a test unless the mutation itself is under test.
- Model default: overrides. A model reaches for the "full" fix-then-check invocation of a
quality/build tool by habit (it's the everyday command, and "make sure everything's clean" reads
as the safe choice) — this entry blocks that instinct in tests specifically, where it silently
narrows what the test can catch.
Don't double anything the suite can run for real
- Default: no mock, fake or stub for a dependency the suite can own the whole lifetime of —
in-process, or as a subprocess it starts and stops. A SQLite file, a temp directory, a local
queue, your own entrypoint under a subprocess: run the real thing. A third-party HTTP API is the
other side of the line, and a hand-written stand-in for one is correct rather than a compromise.
- The deciding question is that lifetime test, not a list of technologies — a list goes stale and
invites arguing about membership, while "can this suite start it and stop it" answers a new case
on its own.
- Why: it is the premise
db-defaults already selects on. Every default there is chosen partly for
"pytest-local testability with no docker/cloud", and doubling the database throws away the thing
the dependency was picked for. You get to run the real thing because the choice was made to let
you.
- Real is not the same as sandboxed, and running real services makes the difference matter more.
The
tmp_path rule above is the sharp version: a test that reaches Path.home() writes into the
real one. A real service under test needs its own temporary state as much as a fake would.
- Where a framework singleton makes an in-process arrangement dishonest, the answer is a
subprocess fixture, not a mock. Starting the real entrypoint against its own temporary state
reproduces the deployment shape instead of pretending the coupling is absent. Give it a bounded
readiness wait that fails with the child's output — an unbounded condition that can never become
true hangs rather than failing.
- Model default: overrides. Left alone a model reaches for an in-memory fake the moment a test
would otherwise open a file or a socket; patching is the shape most training data shows, and
"tests shouldn't touch the disk" reads as the disciplined choice. It is the wrong instinct
wherever the suite could simply own the real thing.
Full rationale
See references/rationale.md — the sources consulted, the DAMP-vs-DRY
debate as it actually stands, and the fixture-scope reasoning behind the defaults above.
Starter snippet
references/snippets/testing.py is a runnable sketch of the
fixture and parametrize shapes described here.
Editing this skill
This file is copied into ~/.agents/skills/python-testing-conventions at install time, never
symlinked, so editing the deployed copy is local drift and reaches no other machine. Edit the
source in the repo this was installed from, push, and re-run
skills add <that source> --global --skill python-testing-conventions to refresh every project's
copy. If you installed it from someone else's repo rather than your own fork, the source is theirs:
open an issue or a pull request there instead.
1---2name: python-testing-conventions3description: Use when writing or restructuring Python tests — deciding how much duplication a test should carry before it stops being readable, what a fixture should cover and at what scope, when to parametrize instead of writing another test, whether a dependency should be doubled or run for real, and what belongs in a fast default suite versus a slower marked tier. Also for pytest specifics: fixtures, conftest placement, parametrize ids, markers, and keeping a suite from writing into the real home directory. Gives the default answer per question rather than an evaluation, so choices stay consistent across projects instead of drifting session to session, and each entry says whether it overrides a model's own instinct or just confirms it.4---56# Python testing defaults78Personal, agent-maintained Python projects. Split out of `python-conventions` on 2026-08-31, which9keeps the design and style defaults; this skill owns the test suite. Nothing here is tool config —10pytest's own configuration lives with the repo's quality tooling, not here.1112**Each entry says whether it's overriding your own default instinct or just confirming one.** A13capable model already parametrizes value matrices and reaches for `tmp_path` unprompted. This skill14exists for the places a model left alone drifts — inlining the same arrange block into every test15rather than promoting it to a fixture, and reaching for a mock where the suite could own the real16thing.1718## Testing conventions1920- Snippet: [`references/snippets/testing.py`](references/snippets/testing.py)21- Fixtures first, always. Any setup a test needs — a tmp tree, a fake `HOME`, a stubbed `c.run`, a22 constructed object, a monkeypatched env — is a `pytest` fixture (in `conftest.py` once two files23 want it), not lines hand-rolled at the top of each test body. Two reasons, and the second is the24 bigger one: it removes the mechanical duplication, and it **surfaces when the suite is doing the25 same thing three different ways** — three hand-rolled versions of "make a fake repo" hide in three26 test bodies indefinitely; three fixtures named `fake_repo`, `tmp_repo`, and `repo_dir` sit next to27 each other in `conftest.py` and get merged. Reach for the built-ins (`tmp_path`, `monkeypatch`,28 `capsys`, `caplog`) before writing a helper that reimplements one. A helper _function_ is the29 fallback only for setup that needs per-call arguments a fixture can't take — and even then, a30 fixture returning a factory (`make_repo(name)`) usually fits.31- A fake home is two variables, and a suite that runs on Windows finds three more traps on its first32 run there. `expanduser` reads `HOME` on POSIX and `USERPROFILE` on Windows, so a fixture setting33 only the first writes into the real profile once per test. Write a path into a TOML or JSON34 fixture as `path.as_posix()` — in a TOML basic string a backslash opens an escape, and `C:\Users`35 is an invalid `\U`. Pass `encoding="utf-8"` to every `read_text`/`write_text`, in tests as well as36 code: the platform default is a code page there, and a config with an em dash in a comment comes37 back as mojibake or a decode error. Key fake-runner tables and compare path lists through38 `as_posix()` too, since `Path("/repo")` renders as `\repo`. And pin any platform seam39 (`WINDOWS = os.name == "nt"` in the module under test) to the arm the fixtures were written for,40 because a test written on Linux reads the real platform otherwise. Measured 2026-09-05 on a suite41 that had never run on Windows: 136 of 557 red on the first run, every one of them one of these42 five, none of them in the code under test.43- Fixture scope: narrowest that stays correct. For the module-singleton pattern in44 `python-conventions` — construct the expensive object at module/session scope, but reset its45 _mutable_ state via a function-scoped fixture. A `monkeypatch` inside a broad-scoped fixture stays46 live for the whole scope, not just one test — a real, silent cross-test leak source.47- DAMP vs. DRY — a different axis from `python-conventions`' production-code DRY decision, not a48 re-derivation of it: setup mechanics (fixtures/helpers, the _how_) stay DRY; the scenario a test49 verifies (the _what_) stays explicit and readable top-to-bottom in that test. `parametrize` is the50 sanctioned everyday tool for a real input→expected matrix, and is _more_ explicit than N51 copy-pasted bodies, because the varying values are isolated from the fixed logic — attach `ids`52 once values stop being self-explanatory. The line: **if adding a case means adding a value,53 parametrize; if it means changing the test's logic (a branch, a different setup, a different54 assertion), write a new test.** What's actually warned against is collapsing genuinely different55 scenarios into one branching mega-test, or hiding the scenario inside a helper whose name doesn't56 say what it asserts.57- Model default: **mostly confirms, overrides in one direction.** A model parametrizes value58 matrices unprompted, and that's right. What it does _not_ reliably do is promote setup to fixtures59 — left alone it inlines the same three-line arrange block into every test it writes, which is the60 "same thing three ways" failure above. The other narrow override: `python-conventions`' modularity61 abstraction instinct can leak into folding scenarios that differ in _logic_ into one62 parametrized-with-branches test, or into a `check_*` helper that owns the assertion.63- Never run a code-mutating command as part of a test's exercised behavior unless the test's actual64 subject is that mutation. A fix/format/autocorrect command run before the assertion silently masks65 the exact defect a check-only equivalent would have caught. Confirmed live 2026-08-23 in66 `scaffoldapy`: an e2e test ran `inv quality.precommit` (fixes formatting, _then_ checks) against a67 freshly generated repo — real CI runs the check-only `inv quality.check` with no such gate, so a68 dprint markdown-wrapping bug in the generated `README.md`/`SKILL.md` passed this test while69 failing every generated repo's actual first CI run. Prefer the check-only/dry-run form of a70 command in a test unless the mutation itself is under test.71- Model default: **overrides.** A model reaches for the "full" fix-then-check invocation of a72 quality/build tool by habit (it's the everyday command, and "make sure everything's clean" reads73 as the safe choice) — this entry blocks that instinct in tests specifically, where it silently74 narrows what the test can catch.7576### Don't double anything the suite can run for real7778- Default: **no mock, fake or stub for a dependency the suite can own the whole lifetime of** —79 in-process, or as a subprocess it starts and stops. A SQLite file, a temp directory, a local80 queue, your own entrypoint under a subprocess: run the real thing. A third-party HTTP API is the81 other side of the line, and a hand-written stand-in for one is correct rather than a compromise.82- The deciding question is that lifetime test, not a list of technologies — a list goes stale and83 invites arguing about membership, while "can this suite start it and stop it" answers a new case84 on its own.85- Why: it is the premise `db-defaults` already selects on. Every default there is chosen partly for86 "pytest-local testability with no docker/cloud", and doubling the database throws away the thing87 the dependency was picked for. You get to run the real thing _because_ the choice was made to let88 you.89- **Real is not the same as sandboxed, and running real services makes the difference matter more.**90 The `tmp_path` rule above is the sharp version: a test that reaches `Path.home()` writes into the91 real one. A real service under test needs its own temporary state as much as a fake would.92- **Where a framework singleton makes an in-process arrangement dishonest, the answer is a93 subprocess fixture, not a mock.** Starting the real entrypoint against its own temporary state94 reproduces the deployment shape instead of pretending the coupling is absent. Give it a bounded95 readiness wait that fails with the child's output — an unbounded condition that can never become96 true hangs rather than failing.97- Model default: **overrides.** Left alone a model reaches for an in-memory fake the moment a test98 would otherwise open a file or a socket; patching is the shape most training data shows, and99 "tests shouldn't touch the disk" reads as the disciplined choice. It is the wrong instinct100 wherever the suite could simply own the real thing.101102## Full rationale103104See [`references/rationale.md`](references/rationale.md) — the sources consulted, the DAMP-vs-DRY105debate as it actually stands, and the fixture-scope reasoning behind the defaults above.106107## Starter snippet108109[`references/snippets/testing.py`](references/snippets/testing.py) is a runnable sketch of the110fixture and parametrize shapes described here.111112## Editing this skill113114This file is _copied_ into `~/.agents/skills/python-testing-conventions` at install time, never115symlinked, so **editing the deployed copy is local drift** and reaches no other machine. Edit the116source in the repo this was installed from, push, and re-run117`skills add <that source> --global --skill python-testing-conventions` to refresh every project's118copy. If you installed it from someone else's repo rather than your own fork, the source is theirs:119open an issue or a pull request there instead.