Internal: pytest rules for FastAPI backends (layers, real PostgreSQL, fixtures); read by work's executors before Python tests.
Resources
2Install
npx skillscat add idontlikedrinkpivo/web-dev-pipeline-codex-plugin/pytest-patterns Install via the SkillsCat registry.
Codex runtime. Before using this skill, read
../executor-catalog/references/codex-runtime.md. It defines tool mappings, named-agent dispatch and stage-chat orchestration; use it for inherited Claude Code terms below.
Pytest Patterns
Which layout governs — decide first
The cases (architecture document, Framework-first, existing repo) and the contract's authority over expected
statuses and bodies are in typescript/references/which-layout-governs.md, shared by the backend and test skills of
both stacks — read it first. The rules every test suite shares (mapper round-trip, race test, property test, grouping,
mutants, no database reachable) are in typescript/references/shared-test-rules.md. pytest's placement in the cases:
- An architecture document exists: tests follow its layers and the foundation's §6 test plan.
- Framework-first: no ports, so no fakes and no mapper tests — the one
domain/module gets plain unit tests, everything else is tested through HTTP against the real database. - Existing repo: its
tests/layout, fixtures and naming; with no tests yet, the unit / integration split below with the repo's own module names.
Test layout
tests/
├── conftest.py # engine, transactional db_session, client, auth fixtures — nothing autouse
├── fakes/ # one in-memory fake per port (dict-backed, implements the Protocol)
├── unit/ # no DB, no network
│ └── <area>/
│ ├── domain/ # entity and value-object tests: one per invariant-table row
│ ├── application/ # use cases with the fakes
│ └── persistence/ # mapper round-trip tests (pure, no connection)
├── integration/ # real PostgreSQL
│ ├── conftest.py # factory session wiring (autouse) lives here, not at the root
│ ├── <area>/ # repository and query adapters against the real DB
│ └── test_<resource>_api.py # HTTP through the app; one test drives the real wiring end to end
├── contract/ # the app's OpenAPI against documentation/api/openapi.yaml (`fastapi` → The contract test)
└── factories/ # storage-record factories, one module per table group- Entity tests — one test per row of the domain model's invariant table (
domain.md§4), named after the rule, asserting the named error, the resulting state or the derived value in the «Исход» column. No DB, no fakes needed. - Use-case tests — the use case built with in-memory fakes for every port it takes; assert what was saved, what was returned, which named error escaped. Never
MagicMock/AsyncMockin place of a port. - Mapper round-trip — shared rule 1, under
unit/<area>/persistence/. - Adapter tests — the repository and query adapters against the real database: save and reload, filters, ordering, constraint violations mapped to the named error. With several tenants, one test proves tenant A never reads or writes B's rows.
- One end-to-end path — at least one API test runs HTTP → use case → real repository for the primary action without overriding anything but the session, proving the
bootstrap/wiring. - Name files
test_<module>.py, classesTest<Feature>(no__init__), functionstest_<action>_<expected_outcome>, fixtures as nouns (db_session,authenticated_client). A failure line should say what broke without opening the file. - Declare markers
unit,integration,slowin[tool.pytest.ini_options]and run subsets with-m. - Use
asyncio_mode = "auto"so async tests need no per-test marker. A session-scoped async engine also needs the session loop scopes thatreferences/conftest-template.pylists (a test in its own loop breaks the engine); check them against the installed pytest-asyncio (references/version-checks.md→ Event-loop scope). - The test database gets its schema from the project's migration command (
alembic upgrade headover the SQL files), nevercreate_all: the CHECK constraints, the project schema and every object the files create take part, and a model that disagrees with the files fails here.references/conftest-template.pydoes it once per session. The one exception is a brownfield repo with no migration files at all: the template's commentedcreate_allfallback stands in until the first migration file exists, and the report says so. - Group tests by behaviour, shared fixtures in
conftest.py(shared rule 4). - Put an autouse fixture that requests
db_sessiononly intests/integration/conftest.py: autouse applies to every test under that directory, and at the root it would make each unit test connect to the database.
Real database, not mocks
- Run integration tests against the project's real engine (PostgreSQL, e.g. in Docker or testcontainers, URL in
TEST_DATABASE_URL— required, a database whose name contains «test», outside CI neverDATABASE_URL(locally a database of its own, not the one a running stand serves); the conftest fails the database tests otherwise and has no default address, so a run never drops the schema of a stand). A mocked session orexecute()hides the query bugs these tests exist to catch. - Isolate tests with one outer transaction per test that is rolled back at the end, with the session joined through a savepoint so the code under test may call
commit()freely. Override the app's session dependency with that session so HTTP requests share it. - Mock only what leaves the process: HTTP APIs (
respx), email/SMS, object storage, queues, time (freezegun), random/UUID. Do not mock the database, SQLAlchemy sessions, Pydantic validation, FastAPI dependency injection, or repositories in integration tests. - Never hand code a
MagicMock/AsyncMockin place of anAsyncSession, in any test layer: it accepts every call, so a wrong query or a missing flush passes. Code that takes the session directly (a brownfieldservice.py, an adapter) is tested against the real database. - If the user explicitly requires SQLite (no Docker in CI), put the driver in the dev dependency group, check the SQLite item of
references/version-checks.md, and say that these tests do not replace a PostgreSQL run. - No PostgreSQL reachable here: shared rule 6 — the integration tests are written, the unit tests run, the integration run reported as owed.
Fixtures
- Default to
functionscope. Usesessionscope only for expensive stateless resources such as the engine; never for mutable data. Every yield fixture cleans up, so test order never matters. - Auth:
auth_headers/admin_headerssign a token withsub=str(user.id)for a persistedsample_user/admin_user;authenticated_client/admin_clientareclientwith that header. A test states its role by the fixture it requests; plainclientis how a 401 test proves an endpoint is protected. - Factories build storage records for adapter and API tests; entity tests construct entities directly. Leave primary keys to the database (a factory-assigned id does not advance the identity sequence, so the app's next insert collides). Use
.build()in unit tests so they stay DB-free; in integration tests add or create through the session andawait db_session.flush()yourself. - Do not
asyncio.gathercalls that sharedb_session: anAsyncSessionis one transaction on one connection. A concurrency test needs one session per task, and that data is committed outside the per-test rollback, so clean it up explicitly. - A guard against a double action gets the race test of shared rule 2:
asyncio.gatherof two calls, each with its own session. - Use pytest-asyncio as the only async test runner; do not also mark tests for the
anyioplugin. One runner keeps one set of loop settings to reason about.
API tests
- Assert the status code first, then the body, so a wrong status fails with the code instead of a
KeyError. - Each operation gets its success case plus one test per error response
documentation/api/openapi.yamldeclares for it — each named error with itscode, the validation answer with the failing fields as the contract names them, the unparsable-request status for a malformed body or cursor, 401 / 403 when secured, 404 when the path names an id. The 500 and the 503 every write declares are the handler's, not the operation's: one test each for the whole app, through a dependency override that raises the failure. Assert the content type the contract gives errors. A status the endpoint returns but the spec does not declare is a spec gap to report, not a test to write. Without a spec, cover the error statuses the endpoint actually returns. A happy-path-only suite passes while the error contract breaks. - Test pagination beyond the first page: the second page through the contract's next-page cursor, the cursor's last-page value, a tampered cursor rejected (brownfield: the repo's own page parameters and their bounds). Limit and cursor bugs show only there.
- Never assert exact timestamps or generated ids (
"id" in data); they change every run. - Use
@pytest.mark.parametrizewithpytest.param(..., id="member-forbidden")for input or role matrices, with the expected result as a parameter, not a branch inside the test.
Coverage
One threshold in one place: fail_under in [tool.coverage.report] — the foundation §6 number when it sets one,
otherwise 80. The full suite runs with coverage in CI, so a total below it fails the build
(repo-scaffold/references/quality-gates.md, the Coverage gate); a unit's focused run during work does not measure. No
per-layer numbers: coverage.py checks one total, and a threshold nothing checks is not a rule. The modules that hold
business decisions (domain/, application/, a brownfield service.py) are covered by the entity, use-case and
error-path tests above, not by a percentage.
- If the project has no
[tool.coverage.*]section, readreferences/coverage-config.mdfor the house block. - Run
uv run pytest --cov=<package> --cov-report=term-missing, orscripts/check-test-coverage.shfor a saved
report (exit 1 tests failed, 2 coverage below threshold, 3 pytest could not run). - A coverage number that looks too low for async SQLAlchemy code is a configuration question before it is a testing
gap (references/version-checks.md→ coverage of async paths).
References
references/version-checks.md— the version-sensitive points. In aworkpacket their answers come inCURRENT LIB DOCS; where it saysnone, probe the installed version in the project instead of fetching docs.
Outsidework, read the file and check each point you rely on against the installed version.references/conftest-template.py— read when creating or rewritingtests/conftest.py: the migrated test database, transactional session, client, auth clients, sample data. Its imports and constants are placeholders; point them at the project's real modules.references/factory-template.py— read when adding factory_boy factories (traits,SubFactoryparents, batches, session wiring).references/coverage-config.md— read when the project has no coverage configuration yet.scripts/check-test-coverage.sh— run for a coverage check with saved report and distinct exit codes.
Properties and mutants
Shared rules 3 and 5: a property test with hypothesis; a surviving mutant gets the assertion that kills it.
The reviewer checks
- No
MagicMock/AsyncMockin place of anAsyncSessionor a port — an in-memory fake per port, the real
database for anything that holds a session. - Every error response the contract declares for a touched operation has its test, asserting the status first,
then thecodeand the error content type. - A guard against a double action has a race test: two calls through
asyncio.gather, each with its own
session, counting the effect. - The test schema comes from the migrations, on PostgreSQL; no
create_all(unless the repo has no migration
files at all), no SQLite stand-in unless the user required it. - No autouse fixture at the root
conftest.pythat touches the database; mutable data is never
session-scoped. - A paged operation is tested past the first page, the last page and a tampered cursor included.