Files
skills/skills/tdd/references/python.md
T
mathias 8da7c9c45f docs(tdd): make the spine language-neutral, add four language references
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
2026-08-29 14:10:10 +02:00

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 sibling tests/. This is the opposite of Go's same-directory convention, and it buys black-box testing structurally: from invoicing.vat import split_vat can only resolve against the installed package, so the preference Go has to state explicitly is enforced here by the import system.
  • No __init__.py in tests/ unless you need shared test packages; pytest's rootdir handling copes, and an accidental package shadows names.
  • conftest.py holds 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__ = True in an assertion helper so failures point at the caller, the way t.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.