When writing pytest (Python):
- No fake tests — every assertion must test real behavior (invariants, boundaries, error semantics)
- Mock ONLY at system boundaries: DB, external HTTP, filesystem, clock. Never mock internal logic
- Real objects for domain types — instantiate real pydantic schemas, ORM rows, internal models with real values.
MagicMockfor a domain object silently accepts any attribute access and lets schema breaks pass — production-only failure mode. Mocks remain ok for system boundaries (DB session, HTTP client, scraper, logger). - Test factories live in conftest — when a test needs a real model/schema/ORM object, add a
make_*factory fixture totests/conftest.py, not a private_make_*helper inside a single test file. Use via dependency injection:def test_x(make_main_table_client): .... The factory takes**overridesso each test can customize. - One test file per production module — mirror the production layout. Prefer
tests/test_<module>.pycovering everything inapp/.../<module>.pyover splitting by concern (e.g.test_<module>_report.py+test_<module>_schedules.py). Split files drift apart, duplicate helpers, and hide shared fixtures. Consolidate before adding new tests when a module already has multiple test files. - Imports at the top of the file — never
from foo import Barinside a test body. - Use existing fixtures from
conftest.pybefore creating new ones - When fixing failing tests: fix test inputs/data to match real behavior — do not add more mocks
- Delete flaky tests outright — do not weaken assertions to make them pass
- HTTP response tests assert status code AND headers, not just body. When testing a route, assert
response.status_codeAND any contract-meaningful headers (X-Cache,Retry-After,WWW-Authenticate, content-type, etc.) — not just the body shape. Weak assertions likestatus != 200orX-Cache != "HIT"miss real bugs (e.g. a201flattened to200on cache replay, or a header silently dropped). Pin the exact value. - Don't hardcode enum/constant copies in fixtures. When a fixture needs an enum value (status, role, type code), import it from the schema/model module, OR add a one-line equality assertion (
assert set(FIXTURE_STATUSES) == {s.value for s in StatusEnum}) that fails when the schema drifts EITHER WAY. A subset assertion (<=) only catches harness-has-invalid-value drift; it silently passes when the schema adds a value the harness doesn't cover (verified: a schema addedUNPLUGGED=13, the harness still had[1..12], every subset test stayed green, the harness silently stopped covering one production state). A standalone list of "looks right" values is a silent contract test for a contract that doesn't exist — and pydantic / DB constraints will reject the drift in production while every unit test passes. - Assert every field on the returned contract, not a spot-check subset. When a test inspects a returned object (mapper output, service return, response body, cached envelope), assert EVERY field on the return schema with an explicit value+type assertion — for the happy-path fixture and for every meaningful variant. Nested objects (embedded relations, status-history rows) get the same treatment recursively. Pair the per-field asserts with a one-line field-set-equality guard:
assert set(response.keys()) == set(Schema.__fields__.keys()). Column-iteration drift-guard loops (for col in __table__.columns: assert getattr(row, col.name) is not None) are additive — they catch newly-added fields being dropped, but they silently pass when TWO fields swap mappings because both still hold some value. Three complementary layers: per-field pins the intended value, set-equality catches additions, the drift loop catches removals. Any partial spot-check ships the wrong-value bug to prod. - Match
event_loopfixture scope to your async singletons' lifetime. If the suite touches any process-cached async resource — a SQLAlchemy async engine perclient_name, a pooled HTTP client, a kafka producer, a redis client — override pytest-asyncio's defaultfunction-scopedevent_loopwith asession-scoped fixture inconftest.py(and setasyncio_default_fixture_loop_scope = sessioninpytest.inifor pytest-asyncio ≥ 0.24). Otherwise pytest-asyncio spins up a new loop per test, the singleton stays attached to the FIRST loop, and later tests blow up withRuntimeError: got Future <...> attached to a different loop— a lifecycle mismatch that looks like a race or flake. Rule of thumb: fixture scope must be ≥ the resource's cache scope. If the singleton is torn down between tests (via a fresh-per-function fixture),functionscope is fine — the mismatch, not either scope, is the bug. - Tapping/seeding a queue shared with a live consumer: account for delivery semantics. When an integration/E2E test reads (or asserts on) a message queue that a running service also consumes, the transport's delivery model decides whether your tap is even possible — a single-delivery queue (SQS) hands each message to exactly ONE reader, so a naive tap races the real consumer and one of them loses the message. See
references/localstack-integration.md§"Tapping a queue shared with a live consumer" for the patterns (non-destructive SQS peek, pause-the-consumer, Kafka unique-group fan-out, purge-stale-then-poll-full-timeout for windowed producers).
When invoked to fix failing tests (not write new ones):
- NO production code changes — fix tests only (unless explicitly told otherwise)
- Debug first: print what the function actually returns before asserting
- Fix test inputs/data to match real behavior — do not add more mocks
- Use existing conftest fixtures — do not create duplicates
- Remove any
@pytest.mark.skiponce the test is passing