$npx -y skills add ocbunknown/fastapi-claude-template --skill testsUse when creating or extending tests under tests/unit/, tests/integration/, or tests/e2e/. Enforces per-use-case unit coverage, per-endpoint e2e coverage, mutation-persistence verification (not just 200 status), contract-vs-Request field-mismatch detection, loads-opt-in flow test
| 1 | # Writing tests (`tests/unit/`, `tests/integration/`, `tests/e2e/`) |
| 2 | |
| 3 | Tests without rules drift into ceremony — "test exists, test passed, shipping". Real bugs this repo shipped because nobody had a test that exercised the exact scenario: |
| 4 | |
| 5 | 1. `UserResult.model_validate(orm_user)` in `update.py` / `create.py` / `permission.py` — crashed with `MissingGreenlet` on PATCH/POST. Not caught because the e2e tests only hit GET. |
| 6 | 2. `UpdateUserRequest` had only `password` field while `AdminUpdateUser` contract had `password + active + role_uuid` — PATCH silently dropped `active` and `role_uuid` (Pydantic `extra="ignore"`), returned 200, and did nothing. Not caught because no test verified that the mutation **persisted** via a follow-up GET. |
| 7 | 3. `select_many` without `loads=role` triggered `MissingGreenlet`. Caught only because an explicit `test_select_users_omits_role_by_default` test existed. Other endpoints had no such test. |
| 8 | |
| 9 | Every "why didn't the tests catch this" answer has the same root: **the scenario was never tested**. This skill lists the scenarios that must exist. |
| 10 | |
| 11 | ## Pick the right layer — don't cross them |
| 12 | |
| 13 | | Layer | What it tests | What it uses | Typical runtime | |
| 14 | |---|---|---|---| |
| 15 | | `tests/unit/` | Pure logic: use cases, validators, helpers, contracts | `MagicMock`/`AsyncMock` DBGateway, `FakeHasher`, `FakeJWT`, `FakeStrCache` from `tests/fakes.py` | < 2s for the whole folder | |
| 16 | | `tests/integration/` | Real I/O: repositories against Postgres, Redis adapters, NATS brokers | Session-scoped `testcontainers` (Postgres/Redis/NATS), per-test outer-transaction rollback | 3–10s | |
| 17 | | `tests/e2e/` | Full HTTP stack: endpoint → contract → request bus → use case → repo → DB | Real app wired via Dishka, `httpx.AsyncClient` + `ASGITransport` | 5–15s | |
| 18 | |
| 19 | **Hard rules:** |
| 20 | - **Unit tests never touch I/O.** No `await database.X.create(...)` against a real DB — that's integration. |
| 21 | - **Integration tests never go through HTTP.** If you want to test the endpoint wiring, that's e2e. |
| 22 | - **E2E tests never replace unit tests.** Unit tests cover branches fast; e2e tests cover a few happy/sad paths against the real stack. Don't try to cover every validation edge case in e2e — that belongs in unit (for Pydantic contracts) and integration (for DB constraints). |
| 23 | |
| 24 | ## Unit tests — use cases |
| 25 | |
| 26 | For **every** use case under `application/v1/usecases/<domain>/<action>.py` there is a `tests/unit/test_<action>_<entity>_usecase.py` file. **No exceptions.** If a use case has no unit test, it is not considered finished. |
| 27 | |
| 28 | ### Fixtures |
| 29 | |
| 30 | Shared fakes live in `tests/fakes.py` (`FakeHasher`, `FakeJWT`, `FakeStrCache`). Shared fixtures live in `tests/unit/conftest.py` (`fake_cache`, `fake_hasher`, `fake_jwt`, `auth_service`, `services`, `fake_database`, `stub_user`). Reuse them — do not redefine. |
| 31 | |
| 32 | ### Patterns |
| 33 | |
| 34 | **1. Mock `DBGateway.<repo>.<method>` to return the expected ORM stub, capture call kwargs:** |
| 35 | |
| 36 | ```python |
| 37 | @pytest.fixture |
| 38 | def update_repo_call(fake_database: MagicMock) -> Any: |
| 39 | captured: dict[str, Any] = {} |
| 40 | |
| 41 | def _stub(user_stub: MagicMock) -> None: |
| 42 | async def _update(uuid_arg: Any, /, **kwargs: Any) -> MagicMock: |
| 43 | captured["uuid"] = uuid_arg |
| 44 | captured["kwargs"] = kwargs |
| 45 | return MagicMock(result=lambda: user_stub) |
| 46 | |
| 47 | fake_database.user.update = AsyncMock(side_effect=_update) |
| 48 | |
| 49 | _stub.captured = captured # type: ignore[attr-defined] |
| 50 | return _stub |
| 51 | ``` |
| 52 | |
| 53 | **2. ORM stub must implement `.as_dict()` to mimic `Base.as_dict()`:** |
| 54 | |
| 55 | ```python |
| 56 | def _make_orm_user(login: str = "alice", active: bool = True) -> MagicMock: |
| 57 | user = MagicMock() |
| 58 | user.uuid = uuid.uuid4() |
| 59 | user.login = login |
| 60 | user.active = active |
| 61 | user.as_dict.return_value = { |
| 62 | "uuid": user.uuid, |
| 63 | "login": login, |
| 64 | "active": active, |
| 65 | } |
| 66 | return user |
| 67 | ``` |
| 68 | |
| 69 | Do **not** add `role` to `as_dict.return_value` unless you are specifically testing the eager-loaded case — mimicking the production behavior where unloaded relationships simply aren't in `__dict__`. |
| 70 | |
| 71 | **3. Assert what the use case forwards to the repository, not just the return value:** |
| 72 | |
| 73 | ```python |
| 74 | async def test_update_user_passes_password_hashed( |
| 75 | use_case: UpdateUserUseCase, |
| 76 | update_repo_call: Any, |
| 77 | fake_hasher: FakeHasher, |
| 78 | ) -> None: |
| 79 | update_repo_call(_make_orm_user()) |
| 80 | |
| 81 | await use_case(UpdateUserRequest(user_uuid=uuid.uuid4(), password="plain")) |
| 82 | |
| 83 | assert update_repo_call.captured["kwargs"]["password"] == fake_hasher.hash_pas |