Testing philosophy and layout¶
Layout¶
tests/
├── domain/ # pure unit tests + Hypothesis property tests; mirrors src/claudeloop/domain/
└── application/ # planned (M2+) — fakes for every port, no real I/O
Run everything:
pytest
Run one module, with verbose failures:
pytest tests/domain/test_classify.py -v
Coverage report (already wired into addopts in pyproject.toml):
pytest --cov-report=term-missing
Coverage gates are per-layer, not global¶
There is deliberately no single --cov-fail-under in pyproject.toml.
A single global number would either be trivially satisfied while the one
pure, fully-controllable layer regresses, or block every commit before
application/infrastructure/cli exist at all. Instead, CI runs coverage
separately per layer:
domain/andapplication/(once it exists): 100% required. These layers have no I/O and no third-party dependencies — there is no excuse for an untested branch, and an untested branch here is the most consequential kind of bug, since this is the code deciding whether an unattended, potentially multi-day run keeps going, waits, or gives up.infrastructure/andcli/: a lower floor, since some paths (signal handlers, real SDK error translation) are inherently harder to exercise without a live process. See the# pragma: no coverpolicy below.
Fakes over mocks¶
Every port in application/ports.py (planned) gets a fake
implementation in test code — a real class satisfying the same Protocol,
not a unittest.mock.Mock with stubbed return values. Two concrete reasons:
mypy --strictchecks a fake against the port'sProtocolshape. AMockhas no such check — a port method rename silently breaks nothing in test code until runtime, or worse, not at all.- A fake can carry real (if simplified) behavior, which is what makes
FakeClock/FakeSleeperpossible (see below) — aMockcan only record calls and return canned values, it can't coordinate state the way a real collaborator would.
FakeClock / FakeSleeper — testing a multi-day wait in microseconds¶
domain/waiting.py's policy is designed entirely around instants
(next_probe_instant() -> datetime), never durations, specifically so
application-layer tests never call time.sleep() for real. The planned
pattern:
clock = FakeClock(start=NOW)
sleeper = FakeSleeper(clock) # sleep_until(instant) jumps `clock` straight there
runner = AutonomousRunner(agent_gateway=fake_gateway, clock=clock, sleeper=sleeper, ...)
result = runner.run(plan)
assert result.success
assert sleeper.wait_log == [...] # exactly which instants it was asked to wait until
This is what lets a test simulate a seven-day rate-limit wait, or the
credit-top-up scenario (several failed probes, then success — already
covered at the domain layer by
test_credit_topup_sequence_resumes_after_several_failed_probes in
tests/domain/test_loop.py), in a test that completes in milliseconds of
real wall-clock time. unittest.mock.patch("time.sleep") was considered and
rejected: a real port + fake pair is one thing to reason about, while
patching a stdlib call is something every test file touching timing would
need to remember to do, consistently, forever.
Property tests, not just examples¶
Anything with a numeric or time-based invariant gets a Hypothesis property
test, not just hand-picked examples. This isn't a style preference — it
already found a real bug during development:
test_property_credits_probe_never_in_the_past_and_never_exceeds_ceiling
generated a probe_count of 29 with a backoff factor of 3.0 and triggered
OverflowError: days=1588666142; must have magnitude <= 999999999 — an
unclamped interval * factor**probe_count overflowing timedelta's maximum
magnitude, at a probe count well within what a real multi-day wait could
reach. No example-based test at any specific, hand-picked probe_count
would have caught this before it happened in production. See
ADR 0004
for the fix.
When adding a numeric config field (an interval, a ceiling, a factor, a
budget), ask: what invariant must hold for every valid input, not just the
ones I thought to write down? Then write that as a @given(...) test.
Golden fixtures from real transcripts¶
A real Claude Code transcript captured during development contains a
genuine credits_required 429 rejection. Prefer capturing real observed
payloads like this as fixtures over inventing synthetic ones — the shape of
a real SDK error is rarely exactly what you'd guess it looks like.
# pragma: no cover policy¶
Reserved for genuinely unreachable branches — and every use must carry an inline comment explaining why it's unreachable, not just that it is. Two real examples already in the codebase:
if candidate < now: # pragma: no cover — unreachable: all config intervals are
candidate = now # validated positive in __post_init__, so every branch above
# already yields candidate >= now. Kept as a defensive invariant guard.
# Precondition, not a security gate: CompletionVerdict is the closed union
# {Done, Blocked, Continue} and both other members are handled above, so this
# is exhaustive by construction — asserted here to fail loudly if a future
# variant is added to the union without a matching branch here.
assert isinstance(verdict, Continue) # nosec B101
A # pragma: no cover with no reasoning attached will be rejected in
review — the point of the annotation is to make the reason for the gap
visible, not to make the coverage number look better.