The discipline in tdd was already language-agnostic; the checklist under it was not. A Python user inherited a mandatory `go test -race` box they can never tick and never got told that `mypy --strict` belongs in the red gate. Per #18's own comment and writing-skills' Multi-Language Dilution warning, the main SKILL.md keeps ONE canonical inline example (Go, the default stack) and the other four languages live in reference files: python, typescript, rust, java. The RED run drove every change. A subagent given a Python task with the Go-only skill reported, in its own words: - "Everything from L169 to L244 is Go-only, and the verification checklist at L326 silently inherits that Go-ness — which is why a Python user ends up with a mandatory box (-race) they can never tick and a required gate (mypy --strict) that isn't listed at all." - "For a brand-new function, the first RED is always a collection-time ImportError... The only clean resolutions are a stub that raises NotImplementedError (production code before a red test?) or accepting ImportError as a legitimate red. The skill picks neither, and this is the single biggest gap." It now picks: that red is legitimate, and writing a stub to satisfy the runner is the Iron Law with extra steps. - The Go table-driven exemplar branches on a `wantErr` column inside the loop. Translated literally that is a conditional assertion folding two behaviours into one test — which this skill's own quality table forbids. The agent caught itself mid-translation; both SKILL.md and python.md now say not to port it. Also: the checklist names the static-analysis gate per language instead of assuming `go vet`, and says to strike the concurrency box deliberately where no equivalent exists — a box that cannot be ticked teaches people to skip checklists. Refs #18 Bump-Type: minor
4.6 KiB
TDD in Python
The Iron Law, Red-Green-Refactor and the rationalization table in SKILL.md apply
unchanged. This file replaces the Go-Specific TDD Notes for Python work.
Canonical stack (~/dev/.context/AGENT.md): pytest + pytest-cov, mypy --strict, ruff,
uv, src-layout. Not restated here — that table has one home.
The first red is an ImportError, and that is correct
For a function that does not exist yet, the first run fails at collection:
E ModuleNotFoundError: No module named 'invoicing.vat'
That is a legitimate red. Read it, confirm it names the module you are about to write,
and go to GREEN. Do not create a stub raising NotImplementedError to make pytest
collect. A stub is production code written before a failing test.
Table-driven: parametrize, one behaviour per function
@pytest.mark.parametrize(
("amount_minor", "rate", "expected"),
[
pytest.param(14, Decimal("0.12"), (12, 2), id="tie-rounds-down-to-even"),
pytest.param(42, Decimal("0.12"), (38, 4), id="tie-rounds-up-to-even"),
pytest.param(12500, Decimal("0.25"), (10000, 2500), id="exact-division"),
pytest.param(0, Decimal("0.25"), (0, 0), id="zero-is-not-an-error"),
],
)
def test_split_vat_rounds_half_to_even(
amount_minor: int, rate: Decimal, expected: tuple[int, int]
) -> None:
assert split_vat(amount_minor, rate) == expected
id= is the analogue of Go's t.Run name: it is what appears in the failure output and
what you pass to pytest -k.
Do not port Go's wantErr column. The Go exemplar branches inside the loop —
if tt.wantErr { assert.Error } else { assert.NoError }. Translated literally that
gives a conditional assertion and folds two behaviours into one test, which SKILL.md's
own quality table forbids ("One thing. 'and' in name? Split it."). Error cases get
their own function:
def test_split_vat_rejects_negative_amount() -> None:
with pytest.raises(ValueError, match="amount_minor must be non-negative"):
split_vat(-1, Decimal("0.25"))
match= pins the message, not just the type. Go's assert.Error does not, and the
stricter version is available here for free — use it.
mypy --strict is part of RED, not a chore after GREEN
A red type-check is a red build.
uv run pytest tests/test_vat.py -x -q # the failing test
uv run mypy --strict src tests # same gate, same phase
Under --strict every test function needs -> None, or it is an untyped def and the
type-checker rejects the file — including the file that holds your failing test.
Layout and imports
src/layout, tests in a siblingtests/. This is the opposite of Go's same-directory convention, and it buys black-box testing structurally:from invoicing.vat import split_vatcan only resolve against the installed package, so the preference Go has to state explicitly is enforced here by the import system.- No
__init__.pyintests/unless you need shared test packages; pytest's rootdir handling copes, and an accidental package shadows names. conftest.pyholds fixtures shared across a directory. A fixture in a single test file that only one test uses should be a plain function call instead — fixtures are indirection, and indirection is only worth it at reuse.
No require vs assert
Every pytest assert is fatal to its test; there is no continue-on-failure form. Where
Go would use assert to collect several soft failures, either split the test or use
pytest.approx/a single structural comparison:
assert result == Invoice(id=InvoiceId("42"), net=10000, vat=2500) # one assertion, whole shape
Comparing whole values beats three field assertions: the failure output shows the entire diff rather than the first field that differed.
Fixtures vs helpers
- Helper function — when the setup is cheap and local. It reads top-to-bottom.
- Fixture — when the setup is shared, or needs teardown, or needs a scope
(
session,module) because it is expensive. - Set
__tracebackhide__ = Truein an assertion helper so failures point at the caller, the wayt.Helper()does in Go.
Coverage is not the concurrency gate
SKILL.md's checklist asks for the language's concurrency gate. Python has no -race
equivalent, and pretending pytest --cov fills that slot is dishonest — coverage says
which lines ran, not whether two threads can interleave badly. For a pure function,
strike the box deliberately. For genuinely concurrent code (asyncio, threads), the
substitute is a test that runs the interleaving you fear, plus pytest-timeout so a
deadlock fails rather than hangs.