@robhowley/py-pit-skills 3.1.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +42 -0
- package/package.json +20 -0
- package/skills/alembic-migrations/SKILL.md +391 -0
- package/skills/click-cli/SKILL.md +204 -0
- package/skills/click-cli-linter/SKILL.md +192 -0
- package/skills/code-quality/SKILL.md +398 -0
- package/skills/dockerize-service/SKILL.md +280 -0
- package/skills/fastapi-errors/SKILL.md +319 -0
- package/skills/fastapi-init/SKILL.md +356 -0
- package/skills/pydantic-schemas/SKILL.md +500 -0
- package/skills/pytest-service/SKILL.md +216 -0
- package/skills/settings-config/SKILL.md +248 -0
- package/skills/sqlalchemy-models/SKILL.md +433 -0
- package/skills/uv/SKILL.md +310 -0
|
@@ -0,0 +1,500 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: pydantic-schemas
|
|
3
|
+
description: Opinionated schema architecture for Python API projects
|
|
4
|
+
using Pydantic v2. Defines request, response, query, command, batch,
|
|
5
|
+
and pagination schema patterns to prevent schema drift in new projects.
|
|
6
|
+
disable-model-invocation: false
|
|
7
|
+
---
|
|
8
|
+
|
|
9
|
+
# pydantic-schemas
|
|
10
|
+
|
|
11
|
+
Opinionated guidance for designing **API request/response schemas**
|
|
12
|
+
using **Pydantic v2** in new Python backend projects.
|
|
13
|
+
|
|
14
|
+
This skill is optimized for **0→1 API projects** where schema
|
|
15
|
+
conventions have not yet been established.\
|
|
16
|
+
Its purpose is to prevent **schema drift**, eliminate repeated
|
|
17
|
+
architectural decisions, and enforce a consistent API boundary between
|
|
18
|
+
domain models and external representations.
|
|
19
|
+
|
|
20
|
+
This skill **does not teach Pydantic basics**.\
|
|
21
|
+
It constrains schema architecture so the model consistently generates
|
|
22
|
+
predictable request, response, query, and command structures.
|
|
23
|
+
|
|
24
|
+
------------------------------------------------------------------------
|
|
25
|
+
|
|
26
|
+
# Apply this Skill When
|
|
27
|
+
|
|
28
|
+
Apply this skill when the user:
|
|
29
|
+
|
|
30
|
+
- asks how to structure API schemas
|
|
31
|
+
- asks for request or response models
|
|
32
|
+
- asks about Pydantic models in a FastAPI or API backend
|
|
33
|
+
- is creating a new resource schema
|
|
34
|
+
- is designing pagination or response envelopes
|
|
35
|
+
- is implementing create/update/read models
|
|
36
|
+
- is designing command endpoints, search/filter endpoints, batch
|
|
37
|
+
endpoints, or aggregate/reporting responses
|
|
38
|
+
|
|
39
|
+
**Trigger phrases (user language):**
|
|
40
|
+
|
|
41
|
+
> "add a schema for orders", "how should I structure my Pydantic
|
|
42
|
+
> models?", "I need a request body for creating a user", "what's the
|
|
43
|
+
> right way to do partial updates?", "how do I return paginated
|
|
44
|
+
> results?", "should I use the same model for create and update?",
|
|
45
|
+
> "how do I model a cancel order action?", "I need a search endpoint
|
|
46
|
+
> with filters"
|
|
47
|
+
|
|
48
|
+
Do **not** apply this skill when:
|
|
49
|
+
|
|
50
|
+
- working with internal-only DTOs that never cross an API boundary
|
|
51
|
+
- using Pydantic for local parsing scripts
|
|
52
|
+
- the repository already uses a different schema architecture and the
|
|
53
|
+
user did not request a refactor
|
|
54
|
+
- working on non-API validation tasks
|
|
55
|
+
|
|
56
|
+
------------------------------------------------------------------------
|
|
57
|
+
|
|
58
|
+
# Mission
|
|
59
|
+
|
|
60
|
+
When this skill is active, the model's job is to **produce or extend
|
|
61
|
+
schema code that follows this taxonomy without deviation**, ask no
|
|
62
|
+
unnecessary questions, and leave the caller with working, importable
|
|
63
|
+
schema classes.
|
|
64
|
+
|
|
65
|
+
------------------------------------------------------------------------
|
|
66
|
+
|
|
67
|
+
# Hard Rules
|
|
68
|
+
|
|
69
|
+
These rules prevent the most common schema mistakes in early-stage API
|
|
70
|
+
projects.
|
|
71
|
+
|
|
72
|
+
**MUST NOT:**
|
|
73
|
+
|
|
74
|
+
- Use ORM models as request or response schemas.
|
|
75
|
+
- Reuse a single schema for create, update, and read roles.
|
|
76
|
+
- Accept unknown request fields silently.
|
|
77
|
+
- Apply partial updates from full model dumps.
|
|
78
|
+
- Invent new pagination or envelope formats per endpoint.
|
|
79
|
+
- Place validation or normalization logic in routers when it belongs
|
|
80
|
+
in schema validators.
|
|
81
|
+
- Introduce advanced configuration (alias generators, global strict
|
|
82
|
+
mode, etc.) unless explicitly required.
|
|
83
|
+
- Create multiple competing base schema classes without clear
|
|
84
|
+
responsibility boundaries.
|
|
85
|
+
- Use `Optional[T]` to mean both "field may be omitted" and "field
|
|
86
|
+
may be null" without being deliberate about which behavior is
|
|
87
|
+
intended.
|
|
88
|
+
|
|
89
|
+
If the repository already has established schema conventions, follow
|
|
90
|
+
those conventions unless the user asks to refactor toward this
|
|
91
|
+
structure.
|
|
92
|
+
|
|
93
|
+
------------------------------------------------------------------------
|
|
94
|
+
|
|
95
|
+
# Standard Operating Procedure
|
|
96
|
+
|
|
97
|
+
**Step 1 — Inspect existing schemas**
|
|
98
|
+
|
|
99
|
+
Before generating anything, check whether the repo already has a
|
|
100
|
+
`schemas/` directory or existing Pydantic models. If it does:
|
|
101
|
+
|
|
102
|
+
- Identify the base class in use (if any).
|
|
103
|
+
- Check whether Create/Update/Read roles are already separated.
|
|
104
|
+
- Extend that pattern if it is coherent; do not introduce a parallel
|
|
105
|
+
taxonomy on top of it.
|
|
106
|
+
- Only propose migration toward this skill's taxonomy if the user
|
|
107
|
+
explicitly asks or the existing pattern violates a Hard Rule.
|
|
108
|
+
|
|
109
|
+
**Step 2 — Generate or extend schemas**
|
|
110
|
+
|
|
111
|
+
- If starting fresh: create `schemas/base.py` first, then the
|
|
112
|
+
resource module.
|
|
113
|
+
- If extending: add only the new schema classes needed; do not
|
|
114
|
+
rewrite existing ones without being asked.
|
|
115
|
+
- Apply the appropriate role (Create, Update, Read, Filter, Command,
|
|
116
|
+
Batch, Summary) and base classes from the sections below.
|
|
117
|
+
- Include `model_dump(exclude_unset=True)` usage in the service layer
|
|
118
|
+
whenever an `Update` schema is generated.
|
|
119
|
+
- Use explicit command/filter/batch/summary schemas — do not inline
|
|
120
|
+
these as untyped dicts or ad hoc parameter groups.
|
|
121
|
+
|
|
122
|
+
**Step 3 — Verify and present**
|
|
123
|
+
|
|
124
|
+
- Confirm code is importable with no circular dependencies.
|
|
125
|
+
- Show only the files that changed or were created.
|
|
126
|
+
- Call out any deviation from defaults and why it was made.
|
|
127
|
+
|
|
128
|
+
------------------------------------------------------------------------
|
|
129
|
+
|
|
130
|
+
# Default Schema Layout
|
|
131
|
+
|
|
132
|
+
For new projects schemas SHOULD follow this layout:
|
|
133
|
+
|
|
134
|
+
```
|
|
135
|
+
schemas/
|
|
136
|
+
base.py
|
|
137
|
+
user.py
|
|
138
|
+
order.py
|
|
139
|
+
pagination.py
|
|
140
|
+
common.py
|
|
141
|
+
```
|
|
142
|
+
|
|
143
|
+
`base.py` defines shared base schema types:
|
|
144
|
+
|
|
145
|
+
- APIModel
|
|
146
|
+
- CreateModel
|
|
147
|
+
- UpdateModel
|
|
148
|
+
- ReadModel
|
|
149
|
+
- QueryModel
|
|
150
|
+
- CommandModel
|
|
151
|
+
- BatchModel
|
|
152
|
+
|
|
153
|
+
Resource schemas should live in their own module (e.g. `schemas/user.py`).
|
|
154
|
+
|
|
155
|
+
------------------------------------------------------------------------
|
|
156
|
+
|
|
157
|
+
# Schema Taxonomy
|
|
158
|
+
|
|
159
|
+
Each API should use explicit schema roles. Do not collapse unrelated
|
|
160
|
+
endpoint types into CRUD-only naming.
|
|
161
|
+
|
|
162
|
+
| Schema Role | Purpose |
|
|
163
|
+
|--------------------|--------------------------------------|
|
|
164
|
+
| Create | Request body for resource creation |
|
|
165
|
+
| Update | Partial update payload |
|
|
166
|
+
| Read | Response serialization for a resource |
|
|
167
|
+
| Filter / Query | Search, list, and filtering inputs |
|
|
168
|
+
| CommandRequest | Action-oriented request body |
|
|
169
|
+
| CommandResponse | Action-oriented response body |
|
|
170
|
+
| BatchRequest | Batch operation request body |
|
|
171
|
+
| Page | Paginated response envelope |
|
|
172
|
+
| Summary / Report | Aggregate or computed response shape |
|
|
173
|
+
| Internal (optional) | Service-layer DTO |
|
|
174
|
+
|
|
175
|
+
Rules:
|
|
176
|
+
|
|
177
|
+
- ORM models MUST NOT cross the API boundary
|
|
178
|
+
- Read models MUST NOT be reused for input
|
|
179
|
+
- Create models MUST declare required fields explicitly
|
|
180
|
+
- Update models MUST represent partial updates
|
|
181
|
+
- Command endpoints SHOULD use explicit command schemas even for small
|
|
182
|
+
payloads
|
|
183
|
+
- Aggregate/reporting endpoints MUST use explicit response schemas
|
|
184
|
+
rather than raw dictionaries
|
|
185
|
+
|
|
186
|
+
------------------------------------------------------------------------
|
|
187
|
+
|
|
188
|
+
# Base Model Configuration
|
|
189
|
+
|
|
190
|
+
All schemas MUST inherit from a shared base class.
|
|
191
|
+
|
|
192
|
+
Example:
|
|
193
|
+
|
|
194
|
+
```python
|
|
195
|
+
from pydantic import BaseModel, ConfigDict
|
|
196
|
+
|
|
197
|
+
class APIModel(BaseModel):
|
|
198
|
+
model_config = ConfigDict(
|
|
199
|
+
extra="forbid",
|
|
200
|
+
str_strip_whitespace=True,
|
|
201
|
+
validate_assignment=True,
|
|
202
|
+
use_enum_values=True,
|
|
203
|
+
populate_by_name=True,
|
|
204
|
+
)
|
|
205
|
+
```
|
|
206
|
+
|
|
207
|
+
These defaults enforce:
|
|
208
|
+
|
|
209
|
+
- strict request validation
|
|
210
|
+
- consistent whitespace normalization
|
|
211
|
+
- safe mutation in service-layer logic
|
|
212
|
+
- predictable enum serialization
|
|
213
|
+
|
|
214
|
+
------------------------------------------------------------------------
|
|
215
|
+
|
|
216
|
+
# Read Model (ORM Serialization)
|
|
217
|
+
|
|
218
|
+
Response models MUST support serialization from ORM objects.
|
|
219
|
+
|
|
220
|
+
```python
|
|
221
|
+
from pydantic import ConfigDict
|
|
222
|
+
|
|
223
|
+
class ReadModel(APIModel):
|
|
224
|
+
model_config = ConfigDict(
|
|
225
|
+
extra="forbid",
|
|
226
|
+
str_strip_whitespace=True,
|
|
227
|
+
validate_assignment=True,
|
|
228
|
+
use_enum_values=True,
|
|
229
|
+
populate_by_name=True,
|
|
230
|
+
from_attributes=True,
|
|
231
|
+
)
|
|
232
|
+
```
|
|
233
|
+
|
|
234
|
+
ORM objects SHOULD be converted using:
|
|
235
|
+
|
|
236
|
+
```python
|
|
237
|
+
UserRead.model_validate(user)
|
|
238
|
+
```
|
|
239
|
+
|
|
240
|
+
Avoid:
|
|
241
|
+
|
|
242
|
+
- manual dict conversion
|
|
243
|
+
- returning ORM objects directly
|
|
244
|
+
- mixing serialization logic in routers
|
|
245
|
+
|
|
246
|
+
------------------------------------------------------------------------
|
|
247
|
+
|
|
248
|
+
# Create Models
|
|
249
|
+
|
|
250
|
+
Create models represent **new resource creation**.
|
|
251
|
+
|
|
252
|
+
Rules:
|
|
253
|
+
|
|
254
|
+
- required fields MUST be explicit
|
|
255
|
+
- optional fields SHOULD only be used when necessary
|
|
256
|
+
- defaults belong in the schema, not router logic
|
|
257
|
+
|
|
258
|
+
Example:
|
|
259
|
+
|
|
260
|
+
```python
|
|
261
|
+
class UserCreate(CreateModel):
|
|
262
|
+
email: EmailStr
|
|
263
|
+
name: str
|
|
264
|
+
```
|
|
265
|
+
|
|
266
|
+
------------------------------------------------------------------------
|
|
267
|
+
|
|
268
|
+
# Update Models
|
|
269
|
+
|
|
270
|
+
Update models represent **partial updates**.
|
|
271
|
+
|
|
272
|
+
Fields SHOULD be optional when omission means "leave unchanged":
|
|
273
|
+
|
|
274
|
+
```python
|
|
275
|
+
class UserUpdate(UpdateModel):
|
|
276
|
+
name: str | None = None
|
|
277
|
+
email: EmailStr | None = None
|
|
278
|
+
```
|
|
279
|
+
|
|
280
|
+
Updates MUST apply only provided fields:
|
|
281
|
+
|
|
282
|
+
```python
|
|
283
|
+
updates = update.model_dump(exclude_unset=True)
|
|
284
|
+
|
|
285
|
+
for field, value in updates.items():
|
|
286
|
+
setattr(user, field, value)
|
|
287
|
+
```
|
|
288
|
+
|
|
289
|
+
Full model dumps MUST NOT be used for partial updates.
|
|
290
|
+
|
|
291
|
+
Be explicit about the difference between:
|
|
292
|
+
|
|
293
|
+
- omitted field: do not change existing value
|
|
294
|
+
- explicit `null`: clear the value, if the API allows it
|
|
295
|
+
|
|
296
|
+
Do not blur these two behaviors accidentally.
|
|
297
|
+
|
|
298
|
+
------------------------------------------------------------------------
|
|
299
|
+
|
|
300
|
+
# Query / Filter Schemas
|
|
301
|
+
|
|
302
|
+
Search and listing endpoints SHOULD use explicit query/filter schemas
|
|
303
|
+
instead of ad hoc parameter groupings.
|
|
304
|
+
|
|
305
|
+
Example:
|
|
306
|
+
|
|
307
|
+
```python
|
|
308
|
+
class UserFilter(QueryModel):
|
|
309
|
+
email: str | None = None
|
|
310
|
+
active: bool | None = None
|
|
311
|
+
created_after: datetime | None = None
|
|
312
|
+
```
|
|
313
|
+
|
|
314
|
+
Use filter/query schemas for:
|
|
315
|
+
|
|
316
|
+
- list endpoints
|
|
317
|
+
- search endpoints
|
|
318
|
+
- reporting filters
|
|
319
|
+
- complex query parameter sets
|
|
320
|
+
|
|
321
|
+
Do not invent different filter naming conventions per endpoint.
|
|
322
|
+
|
|
323
|
+
------------------------------------------------------------------------
|
|
324
|
+
|
|
325
|
+
# Command Request / Response Schemas
|
|
326
|
+
|
|
327
|
+
Non-CRUD actions SHOULD use explicit command request/response schemas.
|
|
328
|
+
|
|
329
|
+
Examples of command endpoints:
|
|
330
|
+
|
|
331
|
+
- activate user
|
|
332
|
+
- cancel order
|
|
333
|
+
- refund payment
|
|
334
|
+
- generate report
|
|
335
|
+
|
|
336
|
+
Example:
|
|
337
|
+
|
|
338
|
+
```python
|
|
339
|
+
class RefundPaymentRequest(CommandModel):
|
|
340
|
+
reason: str
|
|
341
|
+
notify_customer: bool = True
|
|
342
|
+
|
|
343
|
+
class RefundPaymentResponse(APIModel):
|
|
344
|
+
payment_id: int
|
|
345
|
+
status: str
|
|
346
|
+
refunded_at: datetime
|
|
347
|
+
```
|
|
348
|
+
|
|
349
|
+
Do not inline command payloads as untyped dictionaries.
|
|
350
|
+
|
|
351
|
+
Naming pattern — pick one and use it consistently:
|
|
352
|
+
|
|
353
|
+
- `{Action}{Resource}Request`, or
|
|
354
|
+
- `{Resource}{Action}Request`
|
|
355
|
+
|
|
356
|
+
------------------------------------------------------------------------
|
|
357
|
+
|
|
358
|
+
# Batch Request Schemas
|
|
359
|
+
|
|
360
|
+
Batch operations SHOULD use explicit batch request models.
|
|
361
|
+
|
|
362
|
+
Example:
|
|
363
|
+
|
|
364
|
+
```python
|
|
365
|
+
class BulkDisableUsersRequest(BatchModel):
|
|
366
|
+
user_ids: list[int]
|
|
367
|
+
```
|
|
368
|
+
|
|
369
|
+
Do not pass bare lists as request bodies when the payload has semantic
|
|
370
|
+
meaning.
|
|
371
|
+
|
|
372
|
+
------------------------------------------------------------------------
|
|
373
|
+
|
|
374
|
+
# Aggregate / Summary Response Schemas
|
|
375
|
+
|
|
376
|
+
Computed endpoints MUST use explicit response schemas.
|
|
377
|
+
|
|
378
|
+
Examples:
|
|
379
|
+
|
|
380
|
+
- analytics
|
|
381
|
+
- reports
|
|
382
|
+
- summaries
|
|
383
|
+
- stats
|
|
384
|
+
|
|
385
|
+
Example:
|
|
386
|
+
|
|
387
|
+
```python
|
|
388
|
+
class RevenueSummary(APIModel):
|
|
389
|
+
total_revenue: Decimal
|
|
390
|
+
total_orders: int
|
|
391
|
+
average_order_value: Decimal
|
|
392
|
+
```
|
|
393
|
+
|
|
394
|
+
Do not return raw dictionaries for stable API responses.
|
|
395
|
+
|
|
396
|
+
------------------------------------------------------------------------
|
|
397
|
+
|
|
398
|
+
# Response Envelopes
|
|
399
|
+
|
|
400
|
+
APIs SHOULD use a consistent pagination structure.
|
|
401
|
+
|
|
402
|
+
```python
|
|
403
|
+
from typing import Generic, TypeVar
|
|
404
|
+
from pydantic import BaseModel
|
|
405
|
+
|
|
406
|
+
T = TypeVar("T")
|
|
407
|
+
|
|
408
|
+
class Page(BaseModel, Generic[T]):
|
|
409
|
+
items: list[T]
|
|
410
|
+
total: int
|
|
411
|
+
limit: int
|
|
412
|
+
offset: int
|
|
413
|
+
```
|
|
414
|
+
|
|
415
|
+
Endpoints SHOULD return:
|
|
416
|
+
|
|
417
|
+
```python
|
|
418
|
+
Page[UserRead]
|
|
419
|
+
```
|
|
420
|
+
|
|
421
|
+
Resource-specific pagination types MUST NOT be created unless necessary.
|
|
422
|
+
|
|
423
|
+
------------------------------------------------------------------------
|
|
424
|
+
|
|
425
|
+
# Validation Location
|
|
426
|
+
|
|
427
|
+
Normalization and validation SHOULD live in **Pydantic validators**, not
|
|
428
|
+
routers or services.
|
|
429
|
+
|
|
430
|
+
Example:
|
|
431
|
+
|
|
432
|
+
```python
|
|
433
|
+
from pydantic import field_validator
|
|
434
|
+
|
|
435
|
+
class UserCreate(CreateModel):
|
|
436
|
+
email: EmailStr
|
|
437
|
+
|
|
438
|
+
@field_validator("email")
|
|
439
|
+
@classmethod
|
|
440
|
+
def normalize_email(cls, value: str) -> str:
|
|
441
|
+
return value.lower()
|
|
442
|
+
```
|
|
443
|
+
|
|
444
|
+
Routers and services SHOULD assume validated input.
|
|
445
|
+
|
|
446
|
+
------------------------------------------------------------------------
|
|
447
|
+
|
|
448
|
+
# Naming Conventions
|
|
449
|
+
|
|
450
|
+
Schemas MUST follow explicit naming patterns.
|
|
451
|
+
|
|
452
|
+
Recommended patterns:
|
|
453
|
+
|
|
454
|
+
- `{Resource}Create`
|
|
455
|
+
- `{Resource}Update`
|
|
456
|
+
- `{Resource}Read`
|
|
457
|
+
- `{Resource}Filter`
|
|
458
|
+
- `{Action}{Resource}Request`
|
|
459
|
+
- `{Action}{Resource}Response`
|
|
460
|
+
- `{Resource}Summary`
|
|
461
|
+
|
|
462
|
+
Avoid ambiguous names such as:
|
|
463
|
+
|
|
464
|
+
- UserSchema
|
|
465
|
+
- UserDTO
|
|
466
|
+
- UserResponse
|
|
467
|
+
- UserAction
|
|
468
|
+
- ProcessUser
|
|
469
|
+
|
|
470
|
+
Schema names should clearly communicate their role.
|
|
471
|
+
|
|
472
|
+
------------------------------------------------------------------------
|
|
473
|
+
|
|
474
|
+
# Completion Checklist
|
|
475
|
+
|
|
476
|
+
Before responding, verify:
|
|
477
|
+
|
|
478
|
+
- [ ] Existing schemas were inspected before generating new ones
|
|
479
|
+
- [ ] Create, Update, and Read roles are separated (no shared schemas)
|
|
480
|
+
- [ ] All schemas inherit from `APIModel` or `ReadModel`
|
|
481
|
+
- [ ] Update logic uses `model_dump(exclude_unset=True)`
|
|
482
|
+
- [ ] Omit-vs-null semantics are explicit in Update models
|
|
483
|
+
- [ ] No ORM models cross the API boundary
|
|
484
|
+
- [ ] Command/filter/batch/summary endpoints use explicit schemas, not dicts
|
|
485
|
+
- [ ] Pagination uses `Page[T]`, not a resource-specific type
|
|
486
|
+
- [ ] No new base classes were introduced without clear purpose
|
|
487
|
+
- [ ] Code shown is importable as written
|
|
488
|
+
|
|
489
|
+
------------------------------------------------------------------------
|
|
490
|
+
|
|
491
|
+
# Outcome
|
|
492
|
+
|
|
493
|
+
Applying this skill ensures:
|
|
494
|
+
|
|
495
|
+
- predictable schema architecture
|
|
496
|
+
- strict request validation
|
|
497
|
+
- safe ORM serialization
|
|
498
|
+
- explicit command/query/batch/summary models
|
|
499
|
+
- consistent response structures
|
|
500
|
+
- maintainable API evolution
|
|
@@ -0,0 +1,216 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: pytest-service
|
|
3
|
+
description: Write disciplined backend tests for FastAPI services with pytest. Use
|
|
4
|
+
this skill when adding tests to a Python/FastAPI service, setting up SQLAlchemy
|
|
5
|
+
test fixtures, wiring TestClient with DI overrides, mocking external clients, or
|
|
6
|
+
improving test maintainability. Covers fixture design, factory patterns, SQLite
|
|
7
|
+
in-memory databases, app.dependency_overrides, and avoiding fragile or redundant
|
|
8
|
+
tests.
|
|
9
|
+
disable-model-invocation: false
|
|
10
|
+
---
|
|
11
|
+
|
|
12
|
+
# Skill: pytest-service
|
|
13
|
+
|
|
14
|
+
## Core position
|
|
15
|
+
|
|
16
|
+
This skill establishes **clean, reliable backend testing using pytest**.
|
|
17
|
+
|
|
18
|
+
The goal is to produce tests that are:
|
|
19
|
+
|
|
20
|
+
- fast
|
|
21
|
+
- deterministic
|
|
22
|
+
- easy to read
|
|
23
|
+
- easy to extend
|
|
24
|
+
- free of infrastructure dependencies
|
|
25
|
+
|
|
26
|
+
Tests should **optimize for scanability and maintainability**, not
|
|
27
|
+
clever abstractions.
|
|
28
|
+
|
|
29
|
+
## Core testing stance
|
|
30
|
+
|
|
31
|
+
Prefer **simple local tests with minimal infrastructure**.
|
|
32
|
+
|
|
33
|
+
Rules:
|
|
34
|
+
|
|
35
|
+
- Prefer **`TestClient`** for FastAPI endpoint tests whenever possible
|
|
36
|
+
- Use **async clients only when the test genuinely requires async behavior**
|
|
37
|
+
- Default test databases to **SQLite via SQLAlchemy fixtures**
|
|
38
|
+
- Do **not introduce Docker databases** unless the repository already uses them for testing
|
|
39
|
+
- External clients must be **mocked**, not called
|
|
40
|
+
- **No test classes** — top-level `test_*` functions only; fixtures handle all setup
|
|
41
|
+
- Each test covers a **distinct behavior** — no redundant assertions across tests
|
|
42
|
+
- Avoid mutable default arguments in fixtures and helpers
|
|
43
|
+
|
|
44
|
+
Tests must run reliably with a simple `pytest`. No external services required.
|
|
45
|
+
|
|
46
|
+
## Project structure
|
|
47
|
+
|
|
48
|
+
Tests should live in a `tests/` directory.
|
|
49
|
+
|
|
50
|
+
tests/
|
|
51
|
+
conftest.py
|
|
52
|
+
test_health.py
|
|
53
|
+
test_users.py
|
|
54
|
+
factories/
|
|
55
|
+
user_factory.py
|
|
56
|
+
|
|
57
|
+
- `conftest.py` holds shared fixtures
|
|
58
|
+
- test files mirror application modules when possible
|
|
59
|
+
- factory fixtures may live in `tests/factories/` or `conftest.py`
|
|
60
|
+
|
|
61
|
+
## Fixture discipline
|
|
62
|
+
|
|
63
|
+
Shared setup belongs in **pytest fixtures**, not duplicated code.
|
|
64
|
+
|
|
65
|
+
- Use `@pytest.fixture` for reusable setup
|
|
66
|
+
- Inspect existing fixtures **before creating new ones**
|
|
67
|
+
- Prefer **composing fixtures** instead of duplicating setup logic
|
|
68
|
+
- Keep fixture responsibilities narrow and clearly named
|
|
69
|
+
|
|
70
|
+
Avoid inline setup repeated across tests.
|
|
71
|
+
|
|
72
|
+
## Fixture scope
|
|
73
|
+
|
|
74
|
+
Choose fixture scope based on what the fixture creates and how expensive it is.
|
|
75
|
+
|
|
76
|
+
- `scope="session"` — for things that are expensive to create and safe to share (e.g., the SQLAlchemy engine, schema creation)
|
|
77
|
+
- `scope="function"` (default) — for anything that holds mutable state or must be isolated per test (e.g., sessions, clients, mocks)
|
|
78
|
+
|
|
79
|
+
A common pattern: session-scoped engine, function-scoped session with rollback.
|
|
80
|
+
|
|
81
|
+
``` python
|
|
82
|
+
@pytest.fixture(scope="session")
|
|
83
|
+
def engine():
|
|
84
|
+
eng = create_engine("sqlite:///:memory:", connect_args={"check_same_thread": False})
|
|
85
|
+
Base.metadata.create_all(eng)
|
|
86
|
+
yield eng
|
|
87
|
+
Base.metadata.drop_all(eng)
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
@pytest.fixture
|
|
91
|
+
def db_session(engine) -> Session:
|
|
92
|
+
connection = engine.connect()
|
|
93
|
+
transaction = connection.begin()
|
|
94
|
+
TestSession = sessionmaker(bind=connection)
|
|
95
|
+
session = TestSession()
|
|
96
|
+
yield session
|
|
97
|
+
session.close()
|
|
98
|
+
transaction.rollback()
|
|
99
|
+
connection.close()
|
|
100
|
+
```
|
|
101
|
+
|
|
102
|
+
The rollback in teardown means tests never bleed into each other even when they write data.
|
|
103
|
+
|
|
104
|
+
## Factory fixtures
|
|
105
|
+
|
|
106
|
+
Use **factory fixtures** when tests require variations of objects. Factories are appropriate for sample data, request payloads, ORM objects, mocked clients, and service configurations — not just data.
|
|
107
|
+
|
|
108
|
+
``` python
|
|
109
|
+
@pytest.fixture
|
|
110
|
+
def user_payload_factory():
|
|
111
|
+
def _factory(**overrides):
|
|
112
|
+
payload = {
|
|
113
|
+
"email": "test@example.com",
|
|
114
|
+
"name": "Test User",
|
|
115
|
+
}
|
|
116
|
+
payload.update(overrides)
|
|
117
|
+
return payload
|
|
118
|
+
|
|
119
|
+
return _factory
|
|
120
|
+
```
|
|
121
|
+
|
|
122
|
+
## Avoid branching logic in fixtures
|
|
123
|
+
|
|
124
|
+
Fixtures should configure objects, not contain decision trees.
|
|
125
|
+
|
|
126
|
+
Bad:
|
|
127
|
+
|
|
128
|
+
``` python
|
|
129
|
+
if status == "ok":
|
|
130
|
+
client.charge.return_value = ...
|
|
131
|
+
elif status == "declined":
|
|
132
|
+
...
|
|
133
|
+
```
|
|
134
|
+
|
|
135
|
+
Good:
|
|
136
|
+
|
|
137
|
+
``` python
|
|
138
|
+
@pytest.fixture
|
|
139
|
+
def payment_client_factory():
|
|
140
|
+
def _factory(charge_response=None):
|
|
141
|
+
charge_response = charge_response or {"status": "ok"}
|
|
142
|
+
|
|
143
|
+
client = MagicMock(spec=PaymentClient)
|
|
144
|
+
client.charge.return_value = charge_response
|
|
145
|
+
return client
|
|
146
|
+
|
|
147
|
+
return _factory
|
|
148
|
+
```
|
|
149
|
+
|
|
150
|
+
Tests should control behavior explicitly rather than relying on fixture branching.
|
|
151
|
+
|
|
152
|
+
## FastAPI dependency overrides
|
|
153
|
+
|
|
154
|
+
Use **`app.dependency_overrides`** to swap FastAPI dependencies in tests. Do not patch internals directly.
|
|
155
|
+
|
|
156
|
+
``` python
|
|
157
|
+
@pytest.fixture
|
|
158
|
+
def client(db_session):
|
|
159
|
+
def override_get_db():
|
|
160
|
+
yield db_session
|
|
161
|
+
|
|
162
|
+
app.dependency_overrides[get_db] = override_get_db
|
|
163
|
+
with TestClient(app) as c:
|
|
164
|
+
yield c
|
|
165
|
+
app.dependency_overrides.clear()
|
|
166
|
+
```
|
|
167
|
+
|
|
168
|
+
Always clear `dependency_overrides` in teardown so overrides don't leak between tests.
|
|
169
|
+
|
|
170
|
+
## FastAPI testing
|
|
171
|
+
|
|
172
|
+
Prefer `TestClient` for endpoint tests.
|
|
173
|
+
|
|
174
|
+
``` python
|
|
175
|
+
def test_health_endpoint(client):
|
|
176
|
+
response = client.get("/health")
|
|
177
|
+
|
|
178
|
+
assert response.status_code == 200
|
|
179
|
+
assert response.json()["status"] == "ok"
|
|
180
|
+
```
|
|
181
|
+
|
|
182
|
+
## External client mocking
|
|
183
|
+
|
|
184
|
+
External services must be mocked.
|
|
185
|
+
|
|
186
|
+
- use `MagicMock(spec=ClientType)` or `create_autospec`
|
|
187
|
+
- do not make real network calls in unit tests
|
|
188
|
+
- assert the application's behavior, not the external service behavior
|
|
189
|
+
|
|
190
|
+
Shared mock setups should be implemented as **fixtures or factory fixtures**.
|
|
191
|
+
|
|
192
|
+
## Parametrization
|
|
193
|
+
|
|
194
|
+
Use pytest parametrization when only inputs vary.
|
|
195
|
+
|
|
196
|
+
``` python
|
|
197
|
+
@pytest.mark.parametrize(
|
|
198
|
+
"email",
|
|
199
|
+
[
|
|
200
|
+
"user@example.com",
|
|
201
|
+
"admin@example.com",
|
|
202
|
+
],
|
|
203
|
+
)
|
|
204
|
+
def test_email_validation(email):
|
|
205
|
+
...
|
|
206
|
+
```
|
|
207
|
+
|
|
208
|
+
## Deterministic time and data
|
|
209
|
+
|
|
210
|
+
Tests should not depend on the system clock or random values. Freeze time with `freezegun`; use explicit, stable inputs rather than `uuid4()` or `random`.
|
|
211
|
+
|
|
212
|
+
``` python
|
|
213
|
+
@freeze_time("2025-01-01")
|
|
214
|
+
def test_token_expiry():
|
|
215
|
+
...
|
|
216
|
+
```
|