@baselane/packs 0.1.4 → 0.1.5

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.
Files changed (97) hide show
  1. package/dist/aliases.js +1 -0
  2. package/package.json +2 -2
  3. package/packs/database-review/pack.json +75 -9
  4. package/packs/database-review/rendered/.claude/agents/database-reviewer.md +75 -2
  5. package/packs/database-review/rendered/.claude/commands/migration-review.md +6 -0
  6. package/packs/database-review/rendered/.claude/settings.json +11 -0
  7. package/packs/database-review/rendered/.claude/skills/database-migrations/SKILL.md +431 -0
  8. package/packs/database-review/rendered/.claude/skills/mysql-patterns/SKILL.md +414 -0
  9. package/packs/database-review/rendered/.claude/skills/postgres-patterns/SKILL.md +149 -0
  10. package/packs/database-review/rendered/.github/copilot-instructions.md +1096 -14
  11. package/packs/database-review/rendered/AGENTS.md +45 -16
  12. package/packs/database-review/rendered/CLAUDE.md +4 -0
  13. package/packs/database-review/rendered/GEMINI.md +987 -0
  14. package/packs/go-rules/pack.json +65 -9
  15. package/packs/go-rules/rendered/.claude/agents/go-build-resolver.md +90 -0
  16. package/packs/go-rules/rendered/.claude/agents/go-reviewer.md +72 -2
  17. package/packs/go-rules/rendered/.claude/commands/go-build.md +6 -0
  18. package/packs/go-rules/rendered/.claude/commands/go-review.md +1 -1
  19. package/packs/go-rules/rendered/.claude/commands/go-test.md +6 -0
  20. package/packs/go-rules/rendered/.claude/settings.json +9 -0
  21. package/packs/go-rules/rendered/.claude/skills/golang-patterns/SKILL.md +673 -0
  22. package/packs/go-rules/rendered/.claude/skills/golang-testing/SKILL.md +719 -0
  23. package/packs/go-rules/rendered/.github/copilot-instructions.md +1783 -7
  24. package/packs/go-rules/rendered/AGENTS.md +244 -8
  25. package/packs/go-rules/rendered/CLAUDE.md +5 -0
  26. package/packs/go-rules/rendered/GEMINI.md +1394 -0
  27. package/packs/python-rules/pack.json +87 -10
  28. package/packs/python-rules/rendered/.claude/agents/django-reviewer.md +159 -0
  29. package/packs/python-rules/rendered/.claude/agents/fastapi-reviewer.md +70 -0
  30. package/packs/python-rules/rendered/.claude/agents/python-reviewer.md +92 -2
  31. package/packs/python-rules/rendered/.claude/commands/django-review.md +6 -0
  32. package/packs/python-rules/rendered/.claude/commands/fastapi-review.md +6 -0
  33. package/packs/python-rules/rendered/.claude/commands/python-review.md +1 -1
  34. package/packs/python-rules/rendered/.claude/settings.json +1 -1
  35. package/packs/python-rules/rendered/.claude/skills/django-security/SKILL.md +642 -0
  36. package/packs/python-rules/rendered/.claude/skills/django-tdd/SKILL.md +728 -0
  37. package/packs/python-rules/rendered/.claude/skills/fastapi-patterns/SKILL.md +512 -0
  38. package/packs/python-rules/rendered/.claude/skills/python-patterns/SKILL.md +749 -0
  39. package/packs/python-rules/rendered/.claude/skills/python-testing/SKILL.md +815 -0
  40. package/packs/python-rules/rendered/.github/copilot-instructions.md +3815 -12
  41. package/packs/python-rules/rendered/AGENTS.md +73 -13
  42. package/packs/python-rules/rendered/CLAUDE.md +9 -0
  43. package/packs/python-rules/rendered/GEMINI.md +3448 -0
  44. package/packs/security-review/pack.json +51 -7
  45. package/packs/security-review/rendered/.claude/agents/security-reviewer.md +29 -2
  46. package/packs/security-review/rendered/.claude/settings.json +11 -0
  47. package/packs/security-review/rendered/.claude/skills/security-review/SKILL.md +302 -0
  48. package/packs/security-review/rendered/.claude/skills/security-review/cloud-infrastructure-security.md +171 -0
  49. package/packs/security-review/rendered/.claude/skills/security-scan-config/SKILL.md +56 -0
  50. package/packs/security-review/rendered/.github/copilot-instructions.md +658 -15
  51. package/packs/security-review/rendered/AGENTS.md +283 -17
  52. package/packs/security-review/rendered/CLAUDE.md +2 -0
  53. package/packs/security-review/rendered/GEMINI.md +352 -0
  54. package/packs/software-engineer-harness/pack.json +249 -13
  55. package/packs/software-engineer-harness/rendered/.claude/agents/code-explorer.md +67 -0
  56. package/packs/software-engineer-harness/rendered/.claude/agents/performance-optimizer.md +444 -0
  57. package/packs/software-engineer-harness/rendered/.claude/agents/pr-test-analyzer.md +43 -0
  58. package/packs/software-engineer-harness/rendered/.claude/commands/feature-dev.md +6 -0
  59. package/packs/software-engineer-harness/rendered/.claude/settings.json +29 -0
  60. package/packs/software-engineer-harness/rendered/.claude/skills/api-design/SKILL.md +522 -0
  61. package/packs/software-engineer-harness/rendered/.claude/skills/architecture-decision-records/SKILL.md +178 -0
  62. package/packs/software-engineer-harness/rendered/.claude/skills/backend-patterns/SKILL.md +560 -0
  63. package/packs/software-engineer-harness/rendered/.claude/skills/codebase-onboarding/SKILL.md +232 -0
  64. package/packs/software-engineer-harness/rendered/.claude/skills/deployment-patterns/SKILL.md +426 -0
  65. package/packs/software-engineer-harness/rendered/.claude/skills/docker-patterns/SKILL.md +363 -0
  66. package/packs/software-engineer-harness/rendered/.claude/skills/e2e-testing/SKILL.md +325 -0
  67. package/packs/software-engineer-harness/rendered/.claude/skills/tdd-workflow/SKILL.md +580 -0
  68. package/packs/software-engineer-harness/rendered/.github/copilot-instructions.md +3948 -2
  69. package/packs/software-engineer-harness/rendered/AGENTS.md +194 -4
  70. package/packs/software-engineer-harness/rendered/CLAUDE.md +20 -0
  71. package/packs/software-engineer-harness/rendered/GEMINI.md +3208 -0
  72. package/packs/typescript-rules/pack.json +68 -5
  73. package/packs/typescript-rules/rendered/.claude/agents/react-reviewer.md +165 -0
  74. package/packs/typescript-rules/rendered/.claude/agents/type-design-analyzer.md +48 -0
  75. package/packs/typescript-rules/rendered/.claude/commands/react-review.md +6 -0
  76. package/packs/typescript-rules/rendered/.claude/settings.json +9 -0
  77. package/packs/typescript-rules/rendered/.claude/skills/react-patterns/SKILL.md +340 -0
  78. package/packs/typescript-rules/rendered/.claude/skills/react-performance/SKILL.md +573 -0
  79. package/packs/typescript-rules/rendered/.claude/skills/react-testing/SKILL.md +422 -0
  80. package/packs/typescript-rules/rendered/.github/copilot-instructions.md +2459 -53
  81. package/packs/typescript-rules/rendered/AGENTS.md +893 -17
  82. package/packs/typescript-rules/rendered/CLAUDE.md +6 -0
  83. package/packs/typescript-rules/rendered/GEMINI.md +1335 -0
  84. package/packs/ecc-essentials/pack.json +0 -93
  85. package/packs/ecc-essentials/rendered/.claude/settings.json +0 -37
  86. package/packs/ecc-essentials/rendered/.github/copilot-instructions.md +0 -110
  87. package/packs/ecc-essentials/rendered/AGENTS.md +0 -74
  88. package/packs/ecc-essentials/rendered/CLAUDE.md +0 -14
  89. package/packs/ecc-essentials/rendered/GEMINI.md +0 -25
  90. /package/packs/{ecc-essentials → software-engineer-harness}/rendered/.claude/agents/build-error-resolver.md +0 -0
  91. /package/packs/{ecc-essentials → software-engineer-harness}/rendered/.claude/agents/refactor-cleaner.md +0 -0
  92. /package/packs/{ecc-essentials → software-engineer-harness}/rendered/.claude/agents/silent-failure-hunter.md +0 -0
  93. /package/packs/{ecc-essentials → software-engineer-harness}/rendered/.claude/agents/tdd-guide.md +0 -0
  94. /package/packs/{ecc-essentials → software-engineer-harness}/rendered/.claude/commands/fix-build.md +0 -0
  95. /package/packs/{ecc-essentials → software-engineer-harness}/rendered/.claude/commands/hunt-silent-failures.md +0 -0
  96. /package/packs/{ecc-essentials → software-engineer-harness}/rendered/.claude/commands/tdd.md +0 -0
  97. /package/packs/{ecc-essentials → software-engineer-harness}/rendered/.claude/skills/tdd-red-green-refactor/SKILL.md +0 -0
@@ -1,3 +1,3451 @@
1
1
  # GEMINI.md
2
2
 
3
3
  @AGENTS.md
4
+
5
+ ## Skills
6
+
7
+ ### python-patterns
8
+
9
+ Pythonic idioms, type hints, error handling, context managers, comprehensions/generators, dataclasses, decorators, concurrency, package layout, and anti-patterns. Use when writing, reviewing, or refactoring Python code, or designing a package/module layout.
10
+
11
+ # Python Development Patterns
12
+
13
+ Idiomatic Python patterns and best practices for building robust, efficient, and maintainable applications.
14
+
15
+ ## When to Activate
16
+
17
+ - Writing new Python code
18
+ - Reviewing Python code
19
+ - Refactoring existing Python code
20
+ - Designing Python packages/modules
21
+
22
+ ## Core Principles
23
+
24
+ ### 1. Readability Counts
25
+
26
+ Python prioritizes readability. Code should be obvious and easy to understand.
27
+
28
+ ```python
29
+ # Good: Clear and readable
30
+ def get_active_users(users: list[User]) -> list[User]:
31
+ """Return only active users from the provided list."""
32
+ return [user for user in users if user.is_active]
33
+
34
+
35
+ # Bad: Clever but confusing
36
+ def get_active_users(u):
37
+ return [x for x in u if x.a]
38
+ ```
39
+
40
+ ### 2. Explicit is Better Than Implicit
41
+
42
+ Avoid magic; be clear about what your code does.
43
+
44
+ ```python
45
+ # Good: Explicit configuration
46
+ import logging
47
+
48
+ logging.basicConfig(
49
+ level=logging.INFO,
50
+ format='%(asctime)s - %(name)s - %(levelname)s - %(message)s'
51
+ )
52
+
53
+ # Bad: Hidden side effects
54
+ import some_module
55
+ some_module.setup() # What does this do?
56
+ ```
57
+
58
+ ### 3. EAFP - Easier to Ask Forgiveness Than Permission
59
+
60
+ Python prefers exception handling over checking conditions.
61
+
62
+ ```python
63
+ # Good: EAFP style
64
+ def get_value(dictionary: dict, key: str, default_value: Any = None) -> Any:
65
+ try:
66
+ return dictionary[key]
67
+ except KeyError:
68
+ return default_value
69
+
70
+ # Bad: LBYL (Look Before You Leap) style
71
+ def get_value(dictionary: dict, key: str, default_value: Any = None) -> Any:
72
+ if key in dictionary:
73
+ return dictionary[key]
74
+ else:
75
+ return default_value
76
+ ```
77
+
78
+ ## Type Hints
79
+
80
+ ### Basic Type Annotations
81
+
82
+ ```python
83
+ from typing import Optional, List, Dict, Any
84
+
85
+ def process_user(
86
+ user_id: str,
87
+ data: Dict[str, Any],
88
+ active: bool = True
89
+ ) -> Optional[User]:
90
+ """Process a user and return the updated User or None."""
91
+ if not active:
92
+ return None
93
+ return User(user_id, data)
94
+ ```
95
+
96
+ ### Modern Type Hints (Python 3.9+)
97
+
98
+ ```python
99
+ # Python 3.9+ - Use built-in types
100
+ def process_items(items: list[str]) -> dict[str, int]:
101
+ return {item: len(item) for item in items}
102
+
103
+ # Python 3.8 and earlier - Use typing module
104
+ from typing import List, Dict
105
+
106
+ def process_items(items: List[str]) -> Dict[str, int]:
107
+ return {item: len(item) for item in items}
108
+ ```
109
+
110
+ ### Type Aliases and TypeVar
111
+
112
+ ```python
113
+ from typing import TypeVar, Union
114
+
115
+ # Type alias for complex types
116
+ JSON = Union[dict[str, Any], list[Any], str, int, float, bool, None]
117
+
118
+ def parse_json(data: str) -> JSON:
119
+ return json.loads(data)
120
+
121
+ # Generic types
122
+ T = TypeVar('T')
123
+
124
+ def first(items: list[T]) -> T | None:
125
+ """Return the first item or None if list is empty."""
126
+ return items[0] if items else None
127
+ ```
128
+
129
+ ### Protocol-Based Duck Typing
130
+
131
+ ```python
132
+ from typing import Protocol
133
+
134
+ class Renderable(Protocol):
135
+ def render(self) -> str:
136
+ """Render the object to a string."""
137
+
138
+ def render_all(items: list[Renderable]) -> str:
139
+ """Render all items that implement the Renderable protocol."""
140
+ return "\n".join(item.render() for item in items)
141
+ ```
142
+
143
+ ## Error Handling Patterns
144
+
145
+ ### Specific Exception Handling
146
+
147
+ ```python
148
+ # Good: Catch specific exceptions
149
+ def load_config(path: str) -> Config:
150
+ try:
151
+ with open(path) as f:
152
+ return Config.from_json(f.read())
153
+ except FileNotFoundError as e:
154
+ raise ConfigError(f"Config file not found: {path}") from e
155
+ except json.JSONDecodeError as e:
156
+ raise ConfigError(f"Invalid JSON in config: {path}") from e
157
+
158
+ # Bad: Bare except
159
+ def load_config(path: str) -> Config:
160
+ try:
161
+ with open(path) as f:
162
+ return Config.from_json(f.read())
163
+ except:
164
+ return None # Silent failure!
165
+ ```
166
+
167
+ ### Exception Chaining
168
+
169
+ ```python
170
+ def process_data(data: str) -> Result:
171
+ try:
172
+ parsed = json.loads(data)
173
+ except json.JSONDecodeError as e:
174
+ # Chain exceptions to preserve the traceback
175
+ raise ValueError(f"Failed to parse data: {data}") from e
176
+ ```
177
+
178
+ ### Custom Exception Hierarchy
179
+
180
+ ```python
181
+ class AppError(Exception):
182
+ """Base exception for all application errors."""
183
+ pass
184
+
185
+ class ValidationError(AppError):
186
+ """Raised when input validation fails."""
187
+ pass
188
+
189
+ class NotFoundError(AppError):
190
+ """Raised when a requested resource is not found."""
191
+ pass
192
+
193
+ # Usage
194
+ def get_user(user_id: str) -> User:
195
+ user = db.find_user(user_id)
196
+ if not user:
197
+ raise NotFoundError(f"User not found: {user_id}")
198
+ return user
199
+ ```
200
+
201
+ ## Context Managers
202
+
203
+ ### Resource Management
204
+
205
+ ```python
206
+ # Good: Using context managers
207
+ def process_file(path: str) -> str:
208
+ with open(path, 'r') as f:
209
+ return f.read()
210
+
211
+ # Bad: Manual resource management
212
+ def process_file(path: str) -> str:
213
+ f = open(path, 'r')
214
+ try:
215
+ return f.read()
216
+ finally:
217
+ f.close()
218
+ ```
219
+
220
+ ### Custom Context Managers
221
+
222
+ ```python
223
+ from contextlib import contextmanager
224
+
225
+ @contextmanager
226
+ def timer(name: str):
227
+ """Context manager to time a block of code."""
228
+ start = time.perf_counter()
229
+ yield
230
+ elapsed = time.perf_counter() - start
231
+ print(f"{name} took {elapsed:.4f} seconds")
232
+
233
+ # Usage
234
+ with timer("data processing"):
235
+ process_large_dataset()
236
+ ```
237
+
238
+ ### Context Manager Classes
239
+
240
+ ```python
241
+ class DatabaseTransaction:
242
+ def __init__(self, connection):
243
+ self.connection = connection
244
+
245
+ def __enter__(self):
246
+ self.connection.begin_transaction()
247
+ return self
248
+
249
+ def __exit__(self, exc_type, exc_val, exc_tb):
250
+ if exc_type is None:
251
+ self.connection.commit()
252
+ else:
253
+ self.connection.rollback()
254
+ return False # Don't suppress exceptions
255
+
256
+ # Usage
257
+ with DatabaseTransaction(conn):
258
+ user = conn.create_user(user_data)
259
+ conn.create_profile(user.id, profile_data)
260
+ ```
261
+
262
+ ## Comprehensions and Generators
263
+
264
+ ### List Comprehensions
265
+
266
+ ```python
267
+ # Good: List comprehension for simple transformations
268
+ names = [user.name for user in users if user.is_active]
269
+
270
+ # Bad: Manual loop
271
+ names = []
272
+ for user in users:
273
+ if user.is_active:
274
+ names.append(user.name)
275
+
276
+ # Complex comprehensions should be expanded
277
+ # Bad: Too complex
278
+ result = [x * 2 for x in items if x > 0 if x % 2 == 0]
279
+
280
+ # Good: Use a generator function
281
+ def filter_and_transform(items: Iterable[int]) -> list[int]:
282
+ result = []
283
+ for x in items:
284
+ if x > 0 and x % 2 == 0:
285
+ result.append(x * 2)
286
+ return result
287
+ ```
288
+
289
+ ### Generator Expressions
290
+
291
+ ```python
292
+ # Good: Generator for lazy evaluation
293
+ total = sum(x * x for x in range(1_000_000))
294
+
295
+ # Bad: Creates large intermediate list
296
+ total = sum([x * x for x in range(1_000_000)])
297
+ ```
298
+
299
+ ### Generator Functions
300
+
301
+ ```python
302
+ def read_large_file(path: str) -> Iterator[str]:
303
+ """Read a large file line by line."""
304
+ with open(path) as f:
305
+ for line in f:
306
+ yield line.strip()
307
+
308
+ # Usage
309
+ for line in read_large_file("huge.txt"):
310
+ process(line)
311
+ ```
312
+
313
+ ## Data Classes and Named Tuples
314
+
315
+ ### Data Classes
316
+
317
+ ```python
318
+ from dataclasses import dataclass, field
319
+ from datetime import datetime
320
+
321
+ @dataclass
322
+ class User:
323
+ """User entity with automatic __init__, __repr__, and __eq__."""
324
+ id: str
325
+ name: str
326
+ email: str
327
+ created_at: datetime = field(default_factory=datetime.now)
328
+ is_active: bool = True
329
+
330
+ # Usage
331
+ user = User(
332
+ id="123",
333
+ name="Alice",
334
+ email="alice@example.com"
335
+ )
336
+ ```
337
+
338
+ ### Data Classes with Validation
339
+
340
+ ```python
341
+ @dataclass
342
+ class User:
343
+ email: str
344
+ age: int
345
+
346
+ def __post_init__(self):
347
+ # Validate email format
348
+ if "@" not in self.email:
349
+ raise ValueError(f"Invalid email: {self.email}")
350
+ # Validate age range
351
+ if self.age < 0 or self.age > 150:
352
+ raise ValueError(f"Invalid age: {self.age}")
353
+ ```
354
+
355
+ ### Named Tuples
356
+
357
+ ```python
358
+ from typing import NamedTuple
359
+
360
+ class Point(NamedTuple):
361
+ """Immutable 2D point."""
362
+ x: float
363
+ y: float
364
+
365
+ def distance(self, other: 'Point') -> float:
366
+ return ((self.x - other.x) ** 2 + (self.y - other.y) ** 2) ** 0.5
367
+
368
+ # Usage
369
+ p1 = Point(0, 0)
370
+ p2 = Point(3, 4)
371
+ print(p1.distance(p2)) # 5.0
372
+ ```
373
+
374
+ ## Decorators
375
+
376
+ ### Function Decorators
377
+
378
+ ```python
379
+ import functools
380
+ import time
381
+
382
+ def timer(func: Callable) -> Callable:
383
+ """Decorator to time function execution."""
384
+ @functools.wraps(func)
385
+ def wrapper(*args, **kwargs):
386
+ start = time.perf_counter()
387
+ result = func(*args, **kwargs)
388
+ elapsed = time.perf_counter() - start
389
+ print(f"{func.__name__} took {elapsed:.4f}s")
390
+ return result
391
+ return wrapper
392
+
393
+ @timer
394
+ def slow_function():
395
+ time.sleep(1)
396
+
397
+ # slow_function() prints: slow_function took 1.0012s
398
+ ```
399
+
400
+ ### Parameterized Decorators
401
+
402
+ ```python
403
+ def repeat(times: int):
404
+ """Decorator to repeat a function multiple times."""
405
+ def decorator(func: Callable) -> Callable:
406
+ @functools.wraps(func)
407
+ def wrapper(*args, **kwargs):
408
+ results = []
409
+ for _ in range(times):
410
+ results.append(func(*args, **kwargs))
411
+ return results
412
+ return wrapper
413
+ return decorator
414
+
415
+ @repeat(times=3)
416
+ def greet(name: str) -> str:
417
+ return f"Hello, {name}!"
418
+
419
+ # greet("Alice") returns ["Hello, Alice!", "Hello, Alice!", "Hello, Alice!"]
420
+ ```
421
+
422
+ ### Class-Based Decorators
423
+
424
+ ```python
425
+ class CountCalls:
426
+ """Decorator that counts how many times a function is called."""
427
+ def __init__(self, func: Callable):
428
+ functools.update_wrapper(self, func)
429
+ self.func = func
430
+ self.count = 0
431
+
432
+ def __call__(self, *args, **kwargs):
433
+ self.count += 1
434
+ print(f"{self.func.__name__} has been called {self.count} times")
435
+ return self.func(*args, **kwargs)
436
+
437
+ @CountCalls
438
+ def process():
439
+ pass
440
+
441
+ # Each call to process() prints the call count
442
+ ```
443
+
444
+ ## Concurrency Patterns
445
+
446
+ ### Threading for I/O-Bound Tasks
447
+
448
+ ```python
449
+ import concurrent.futures
450
+ import threading
451
+
452
+ def fetch_url(url: str) -> str:
453
+ """Fetch a URL (I/O-bound operation)."""
454
+ import urllib.request
455
+ with urllib.request.urlopen(url) as response:
456
+ return response.read().decode()
457
+
458
+ def fetch_all_urls(urls: list[str]) -> dict[str, str]:
459
+ """Fetch multiple URLs concurrently using threads."""
460
+ with concurrent.futures.ThreadPoolExecutor(max_workers=10) as executor:
461
+ future_to_url = {executor.submit(fetch_url, url): url for url in urls}
462
+ results = {}
463
+ for future in concurrent.futures.as_completed(future_to_url):
464
+ url = future_to_url[future]
465
+ try:
466
+ results[url] = future.result()
467
+ except Exception as e:
468
+ results[url] = f"Error: {e}"
469
+ return results
470
+ ```
471
+
472
+ ### Multiprocessing for CPU-Bound Tasks
473
+
474
+ ```python
475
+ def process_data(data: list[int]) -> int:
476
+ """CPU-intensive computation."""
477
+ return sum(x ** 2 for x in data)
478
+
479
+ def process_all(datasets: list[list[int]]) -> list[int]:
480
+ """Process multiple datasets using multiple processes."""
481
+ with concurrent.futures.ProcessPoolExecutor() as executor:
482
+ results = list(executor.map(process_data, datasets))
483
+ return results
484
+ ```
485
+
486
+ ### Async/Await for Concurrent I/O
487
+
488
+ ```python
489
+ import asyncio
490
+
491
+ async def fetch_async(url: str) -> str:
492
+ """Fetch a URL asynchronously."""
493
+ import aiohttp
494
+ async with aiohttp.ClientSession() as session:
495
+ async with session.get(url) as response:
496
+ return await response.text()
497
+
498
+ async def fetch_all(urls: list[str]) -> dict[str, str]:
499
+ """Fetch multiple URLs concurrently."""
500
+ tasks = [fetch_async(url) for url in urls]
501
+ results = await asyncio.gather(*tasks, return_exceptions=True)
502
+ return dict(zip(urls, results))
503
+ ```
504
+
505
+ ## Package Organization
506
+
507
+ ### Standard Project Layout
508
+
509
+ ```
510
+ myproject/
511
+ ├── src/
512
+ │ └── mypackage/
513
+ │ ├── __init__.py
514
+ │ ├── main.py
515
+ │ ├── api/
516
+ │ │ ├── __init__.py
517
+ │ │ └── routes.py
518
+ │ ├── models/
519
+ │ │ ├── __init__.py
520
+ │ │ └── user.py
521
+ │ └── utils/
522
+ │ ├── __init__.py
523
+ │ └── helpers.py
524
+ ├── tests/
525
+ │ ├── __init__.py
526
+ │ ├── conftest.py
527
+ │ ├── test_api.py
528
+ │ └── test_models.py
529
+ ├── pyproject.toml
530
+ ├── README.md
531
+ └── .gitignore
532
+ ```
533
+
534
+ ### Import Conventions
535
+
536
+ ```python
537
+ # Good: Import order - stdlib, third-party, local
538
+ import os
539
+ import sys
540
+ from pathlib import Path
541
+
542
+ import requests
543
+ from fastapi import FastAPI
544
+
545
+ from mypackage.models import User
546
+ from mypackage.utils import format_name
547
+
548
+ # Good: Use isort for automatic import sorting
549
+ # pip install isort
550
+ ```
551
+
552
+ ### __init__.py for Package Exports
553
+
554
+ ```python
555
+ # mypackage/__init__.py
556
+ """mypackage - A sample Python package."""
557
+
558
+ __version__ = "1.0.0"
559
+
560
+ # Export main classes/functions at package level
561
+ from mypackage.models import User, Post
562
+ from mypackage.utils import format_name
563
+
564
+ __all__ = ["User", "Post", "format_name"]
565
+ ```
566
+
567
+ ## Memory and Performance
568
+
569
+ ### Using __slots__ for Memory Efficiency
570
+
571
+ ```python
572
+ # Bad: Regular class uses __dict__ (more memory)
573
+ class Point:
574
+ def __init__(self, x: float, y: float):
575
+ self.x = x
576
+ self.y = y
577
+
578
+ # Good: __slots__ reduces memory usage
579
+ class Point:
580
+ __slots__ = ['x', 'y']
581
+
582
+ def __init__(self, x: float, y: float):
583
+ self.x = x
584
+ self.y = y
585
+ ```
586
+
587
+ ### Generator for Large Data
588
+
589
+ ```python
590
+ # Bad: Returns full list in memory
591
+ def read_lines(path: str) -> list[str]:
592
+ with open(path) as f:
593
+ return [line.strip() for line in f]
594
+
595
+ # Good: Yields lines one at a time
596
+ def read_lines(path: str) -> Iterator[str]:
597
+ with open(path) as f:
598
+ for line in f:
599
+ yield line.strip()
600
+ ```
601
+
602
+ ### Avoid String Concatenation in Loops
603
+
604
+ ```python
605
+ # Bad: O(n²) due to string immutability
606
+ result = ""
607
+ for item in items:
608
+ result += str(item)
609
+
610
+ # Good: O(n) using join
611
+ result = "".join(str(item) for item in items)
612
+
613
+ # Good: Using StringIO for building
614
+ from io import StringIO
615
+
616
+ buffer = StringIO()
617
+ for item in items:
618
+ buffer.write(str(item))
619
+ result = buffer.getvalue()
620
+ ```
621
+
622
+ ## Python Tooling Integration
623
+
624
+ ### Essential Commands
625
+
626
+ ```bash
627
+ # Code formatting
628
+ black .
629
+ isort .
630
+
631
+ # Linting
632
+ ruff check .
633
+ pylint mypackage/
634
+
635
+ # Type checking
636
+ mypy .
637
+
638
+ # Testing
639
+ pytest --cov=mypackage --cov-report=html
640
+
641
+ # Security scanning
642
+ bandit -r .
643
+
644
+ # Dependency management
645
+ pip-audit
646
+ safety check
647
+ ```
648
+
649
+ ### pyproject.toml Configuration
650
+
651
+ ```toml
652
+ [project]
653
+ name = "mypackage"
654
+ version = "1.0.0"
655
+ requires-python = ">=3.9"
656
+ dependencies = [
657
+ "requests>=2.31.0",
658
+ "pydantic>=2.0.0",
659
+ ]
660
+
661
+ [project.optional-dependencies]
662
+ dev = [
663
+ "pytest>=7.4.0",
664
+ "pytest-cov>=4.1.0",
665
+ "black>=23.0.0",
666
+ "ruff>=0.1.0",
667
+ "mypy>=1.5.0",
668
+ ]
669
+
670
+ [tool.black]
671
+ line-length = 88
672
+ target-version = ['py39']
673
+
674
+ [tool.ruff]
675
+ line-length = 88
676
+ select = ["E", "F", "I", "N", "W"]
677
+
678
+ [tool.mypy]
679
+ python_version = "3.9"
680
+ warn_return_any = true
681
+ warn_unused_configs = true
682
+ disallow_untyped_defs = true
683
+
684
+ [tool.pytest.ini_options]
685
+ testpaths = ["tests"]
686
+ addopts = "--cov=mypackage --cov-report=term-missing"
687
+ ```
688
+
689
+ ## Quick Reference: Python Idioms
690
+
691
+ | Idiom | Description |
692
+ |-------|-------------|
693
+ | EAFP | Easier to Ask Forgiveness than Permission |
694
+ | Context managers | Use `with` for resource management |
695
+ | List comprehensions | For simple transformations |
696
+ | Generators | For lazy evaluation and large datasets |
697
+ | Type hints | Annotate function signatures |
698
+ | Dataclasses | For data containers with auto-generated methods |
699
+ | `__slots__` | For memory optimization |
700
+ | f-strings | For string formatting (Python 3.6+) |
701
+ | `pathlib.Path` | For path operations (Python 3.4+) |
702
+ | `enumerate` | For index-element pairs in loops |
703
+
704
+ ## Anti-Patterns to Avoid
705
+
706
+ ```python
707
+ # Bad: Mutable default arguments
708
+ def append_to(item, items=[]):
709
+ items.append(item)
710
+ return items
711
+
712
+ # Good: Use None and create new list
713
+ def append_to(item, items=None):
714
+ if items is None:
715
+ items = []
716
+ items.append(item)
717
+ return items
718
+
719
+ # Bad: Checking type with type()
720
+ if type(obj) == list:
721
+ process(obj)
722
+
723
+ # Good: Use isinstance
724
+ if isinstance(obj, list):
725
+ process(obj)
726
+
727
+ # Bad: Comparing to None with ==
728
+ if value == None:
729
+ process()
730
+
731
+ # Good: Use is
732
+ if value is None:
733
+ process()
734
+
735
+ # Bad: from module import *
736
+ from os.path import *
737
+
738
+ # Good: Explicit imports
739
+ from os.path import join, exists
740
+
741
+ # Bad: Bare except
742
+ try:
743
+ risky_operation()
744
+ except:
745
+ pass
746
+
747
+ # Good: Specific exception
748
+ try:
749
+ risky_operation()
750
+ except SpecificError as e:
751
+ logger.error(f"Operation failed: {e}")
752
+ ```
753
+
754
+ __Remember__: Python code should be readable, explicit, and follow the principle of least surprise. When in doubt, prioritize clarity over cleverness.
755
+
756
+ ### python-testing
757
+
758
+ pytest fundamentals, fixtures (scopes, params, autouse, conftest), parametrization, mocking/patching, async test patterns, and TDD methodology with coverage targets. Use when writing or reviewing Python tests, or setting up a pytest suite.
759
+
760
+ # Python Testing Patterns
761
+
762
+ Comprehensive testing strategies for Python applications using pytest, TDD methodology, and best practices.
763
+
764
+ ## When to Activate
765
+
766
+ - Writing new Python code (follow TDD: red, green, refactor)
767
+ - Designing test suites for Python projects
768
+ - Reviewing Python test coverage
769
+ - Setting up testing infrastructure
770
+
771
+ ## Core Testing Philosophy
772
+
773
+ ### Test-Driven Development (TDD)
774
+
775
+ Always follow the TDD cycle:
776
+
777
+ 1. **RED**: Write a failing test for the desired behavior
778
+ 2. **GREEN**: Write minimal code to make the test pass
779
+ 3. **REFACTOR**: Improve code while keeping tests green
780
+
781
+ ```python
782
+ # Step 1: Write failing test (RED)
783
+ def test_add_numbers():
784
+ result = add(2, 3)
785
+ assert result == 5
786
+
787
+ # Step 2: Write minimal implementation (GREEN)
788
+ def add(a, b):
789
+ return a + b
790
+
791
+ # Step 3: Refactor if needed (REFACTOR)
792
+ ```
793
+
794
+ ### Coverage Requirements
795
+
796
+ - **Target**: 80%+ code coverage
797
+ - **Critical paths**: 100% coverage required
798
+ - Use `pytest --cov` to measure coverage
799
+
800
+ ```bash
801
+ pytest --cov=mypackage --cov-report=term-missing --cov-report=html
802
+ ```
803
+
804
+ ## pytest Fundamentals
805
+
806
+ ### Basic Test Structure
807
+
808
+ ```python
809
+ import pytest
810
+
811
+ def test_addition():
812
+ """Test basic addition."""
813
+ assert 2 + 2 == 4
814
+
815
+ def test_string_uppercase():
816
+ """Test string uppercasing."""
817
+ text = "hello"
818
+ assert text.upper() == "HELLO"
819
+
820
+ def test_list_append():
821
+ """Test list append."""
822
+ items = [1, 2, 3]
823
+ items.append(4)
824
+ assert 4 in items
825
+ assert len(items) == 4
826
+ ```
827
+
828
+ ### Assertions
829
+
830
+ ```python
831
+ # Equality
832
+ assert result == expected
833
+
834
+ # Inequality
835
+ assert result != unexpected
836
+
837
+ # Truthiness
838
+ assert result # Truthy
839
+ assert not result # Falsy
840
+ assert result is True # Exactly True
841
+ assert result is False # Exactly False
842
+ assert result is None # Exactly None
843
+
844
+ # Membership
845
+ assert item in collection
846
+ assert item not in collection
847
+
848
+ # Comparisons
849
+ assert result > 0
850
+ assert 0 <= result <= 100
851
+
852
+ # Type checking
853
+ assert isinstance(result, str)
854
+
855
+ # Exception testing (preferred approach)
856
+ with pytest.raises(ValueError):
857
+ raise ValueError("error message")
858
+
859
+ # Check exception message
860
+ with pytest.raises(ValueError, match="invalid input"):
861
+ raise ValueError("invalid input provided")
862
+
863
+ # Check exception attributes
864
+ with pytest.raises(ValueError) as exc_info:
865
+ raise ValueError("error message")
866
+ assert str(exc_info.value) == "error message"
867
+ ```
868
+
869
+ ## Fixtures
870
+
871
+ ### Basic Fixture Usage
872
+
873
+ ```python
874
+ import pytest
875
+
876
+ @pytest.fixture
877
+ def sample_data():
878
+ """Fixture providing sample data."""
879
+ return {"name": "Alice", "age": 30}
880
+
881
+ def test_sample_data(sample_data):
882
+ """Test using the fixture."""
883
+ assert sample_data["name"] == "Alice"
884
+ assert sample_data["age"] == 30
885
+ ```
886
+
887
+ ### Fixture with Setup/Teardown
888
+
889
+ ```python
890
+ @pytest.fixture
891
+ def database():
892
+ """Fixture with setup and teardown."""
893
+ # Setup
894
+ db = Database(":memory:")
895
+ db.create_tables()
896
+ db.insert_test_data()
897
+
898
+ yield db # Provide to test
899
+
900
+ # Teardown
901
+ db.close()
902
+
903
+ def test_database_query(database):
904
+ """Test database operations."""
905
+ result = database.query("SELECT * FROM users")
906
+ assert len(result) > 0
907
+ ```
908
+
909
+ ### Fixture Scopes
910
+
911
+ ```python
912
+ # Function scope (default) - runs for each test
913
+ @pytest.fixture
914
+ def temp_file():
915
+ with open("temp.txt", "w") as f:
916
+ yield f
917
+ os.remove("temp.txt")
918
+
919
+ # Module scope - runs once per module
920
+ @pytest.fixture(scope="module")
921
+ def module_db():
922
+ db = Database(":memory:")
923
+ db.create_tables()
924
+ yield db
925
+ db.close()
926
+
927
+ # Session scope - runs once per test session
928
+ @pytest.fixture(scope="session")
929
+ def shared_resource():
930
+ resource = ExpensiveResource()
931
+ yield resource
932
+ resource.cleanup()
933
+ ```
934
+
935
+ ### Fixture with Parameters
936
+
937
+ ```python
938
+ @pytest.fixture(params=[1, 2, 3])
939
+ def number(request):
940
+ """Parameterized fixture."""
941
+ return request.param
942
+
943
+ def test_numbers(number):
944
+ """Test runs 3 times, once for each parameter."""
945
+ assert number > 0
946
+ ```
947
+
948
+ ### Using Multiple Fixtures
949
+
950
+ ```python
951
+ @pytest.fixture
952
+ def user():
953
+ return User(id=1, name="Alice")
954
+
955
+ @pytest.fixture
956
+ def admin():
957
+ return User(id=2, name="Admin", role="admin")
958
+
959
+ def test_user_admin_interaction(user, admin):
960
+ """Test using multiple fixtures."""
961
+ assert admin.can_manage(user)
962
+ ```
963
+
964
+ ### Autouse Fixtures
965
+
966
+ ```python
967
+ @pytest.fixture(autouse=True)
968
+ def reset_config():
969
+ """Automatically runs before every test."""
970
+ Config.reset()
971
+ yield
972
+ Config.cleanup()
973
+
974
+ def test_without_fixture_call():
975
+ # reset_config runs automatically
976
+ assert Config.get_setting("debug") is False
977
+ ```
978
+
979
+ ### Conftest.py for Shared Fixtures
980
+
981
+ ```python
982
+ # tests/conftest.py
983
+ import pytest
984
+
985
+ @pytest.fixture
986
+ def client():
987
+ """Shared fixture for all tests."""
988
+ app = create_app(testing=True)
989
+ with app.test_client() as client:
990
+ yield client
991
+
992
+ @pytest.fixture
993
+ def auth_headers(client):
994
+ """Generate auth headers for API testing."""
995
+ response = client.post("/api/login", json={
996
+ "username": "test",
997
+ "password": "test"
998
+ })
999
+ token = response.json["token"]
1000
+ return {"Authorization": f"Bearer {token}"}
1001
+ ```
1002
+
1003
+ ## Parametrization
1004
+
1005
+ ### Basic Parametrization
1006
+
1007
+ ```python
1008
+ @pytest.mark.parametrize("input,expected", [
1009
+ ("hello", "HELLO"),
1010
+ ("world", "WORLD"),
1011
+ ("PyThOn", "PYTHON"),
1012
+ ])
1013
+ def test_uppercase(input, expected):
1014
+ """Test runs 3 times with different inputs."""
1015
+ assert input.upper() == expected
1016
+ ```
1017
+
1018
+ ### Multiple Parameters
1019
+
1020
+ ```python
1021
+ @pytest.mark.parametrize("a,b,expected", [
1022
+ (2, 3, 5),
1023
+ (0, 0, 0),
1024
+ (-1, 1, 0),
1025
+ (100, 200, 300),
1026
+ ])
1027
+ def test_add(a, b, expected):
1028
+ """Test addition with multiple inputs."""
1029
+ assert add(a, b) == expected
1030
+ ```
1031
+
1032
+ ### Parametrize with IDs
1033
+
1034
+ ```python
1035
+ @pytest.mark.parametrize("input,expected", [
1036
+ ("valid@email.com", True),
1037
+ ("invalid", False),
1038
+ ("@no-domain.com", False),
1039
+ ], ids=["valid-email", "missing-at", "missing-domain"])
1040
+ def test_email_validation(input, expected):
1041
+ """Test email validation with readable test IDs."""
1042
+ assert is_valid_email(input) is expected
1043
+ ```
1044
+
1045
+ ### Parametrized Fixtures
1046
+
1047
+ ```python
1048
+ @pytest.fixture(params=["sqlite", "postgresql", "mysql"])
1049
+ def db(request):
1050
+ """Test against multiple database backends."""
1051
+ if request.param == "sqlite":
1052
+ return Database(":memory:")
1053
+ elif request.param == "postgresql":
1054
+ return Database("postgresql://localhost/test")
1055
+ elif request.param == "mysql":
1056
+ return Database("mysql://localhost/test")
1057
+
1058
+ def test_database_operations(db):
1059
+ """Test runs 3 times, once for each database."""
1060
+ result = db.query("SELECT 1")
1061
+ assert result is not None
1062
+ ```
1063
+
1064
+ ## Markers and Test Selection
1065
+
1066
+ ### Custom Markers
1067
+
1068
+ ```python
1069
+ # Mark slow tests
1070
+ @pytest.mark.slow
1071
+ def test_slow_operation():
1072
+ time.sleep(5)
1073
+
1074
+ # Mark integration tests
1075
+ @pytest.mark.integration
1076
+ def test_api_integration():
1077
+ response = requests.get("https://api.example.com")
1078
+ assert response.status_code == 200
1079
+
1080
+ # Mark unit tests
1081
+ @pytest.mark.unit
1082
+ def test_unit_logic():
1083
+ assert calculate(2, 3) == 5
1084
+ ```
1085
+
1086
+ ### Run Specific Tests
1087
+
1088
+ ```bash
1089
+ # Run only fast tests
1090
+ pytest -m "not slow"
1091
+
1092
+ # Run only integration tests
1093
+ pytest -m integration
1094
+
1095
+ # Run integration or slow tests
1096
+ pytest -m "integration or slow"
1097
+
1098
+ # Run tests marked as unit but not slow
1099
+ pytest -m "unit and not slow"
1100
+ ```
1101
+
1102
+ ### Configure Markers in pytest.ini
1103
+
1104
+ ```ini
1105
+ [pytest]
1106
+ markers =
1107
+ slow: marks tests as slow
1108
+ integration: marks tests as integration tests
1109
+ unit: marks tests as unit tests
1110
+ django: marks tests as requiring Django
1111
+ ```
1112
+
1113
+ ## Mocking and Patching
1114
+
1115
+ ### Mocking Functions
1116
+
1117
+ ```python
1118
+ from unittest.mock import patch, Mock
1119
+
1120
+ @patch("mypackage.external_api_call")
1121
+ def test_with_mock(api_call_mock):
1122
+ """Test with mocked external API."""
1123
+ api_call_mock.return_value = {"status": "success"}
1124
+
1125
+ result = my_function()
1126
+
1127
+ api_call_mock.assert_called_once()
1128
+ assert result["status"] == "success"
1129
+ ```
1130
+
1131
+ ### Mocking Return Values
1132
+
1133
+ ```python
1134
+ @patch("mypackage.Database.connect")
1135
+ def test_database_connection(connect_mock):
1136
+ """Test with mocked database connection."""
1137
+ connect_mock.return_value = MockConnection()
1138
+
1139
+ db = Database()
1140
+ db.connect()
1141
+
1142
+ connect_mock.assert_called_once_with("localhost")
1143
+ ```
1144
+
1145
+ ### Mocking Exceptions
1146
+
1147
+ ```python
1148
+ @patch("mypackage.api_call")
1149
+ def test_api_error_handling(api_call_mock):
1150
+ """Test error handling with mocked exception."""
1151
+ api_call_mock.side_effect = ConnectionError("Network error")
1152
+
1153
+ with pytest.raises(ConnectionError):
1154
+ api_call()
1155
+
1156
+ api_call_mock.assert_called_once()
1157
+ ```
1158
+
1159
+ ### Mocking Context Managers
1160
+
1161
+ ```python
1162
+ @patch("builtins.open", new_callable=mock_open)
1163
+ def test_file_reading(mock_file):
1164
+ """Test file reading with mocked open."""
1165
+ mock_file.return_value.read.return_value = "file content"
1166
+
1167
+ result = read_file("test.txt")
1168
+
1169
+ mock_file.assert_called_once_with("test.txt", "r")
1170
+ assert result == "file content"
1171
+ ```
1172
+
1173
+ ### Using Autospec
1174
+
1175
+ ```python
1176
+ @patch("mypackage.DBConnection", autospec=True)
1177
+ def test_autospec(db_mock):
1178
+ """Test with autospec to catch API misuse."""
1179
+ db = db_mock.return_value
1180
+ db.query("SELECT * FROM users")
1181
+
1182
+ # This would fail if DBConnection doesn't have query method
1183
+ db_mock.assert_called_once()
1184
+ ```
1185
+
1186
+ ### Mock Class Instances
1187
+
1188
+ ```python
1189
+ class TestUserService:
1190
+ @patch("mypackage.UserRepository")
1191
+ def test_create_user(self, repo_mock):
1192
+ """Test user creation with mocked repository."""
1193
+ repo_mock.return_value.save.return_value = User(id=1, name="Alice")
1194
+
1195
+ service = UserService(repo_mock.return_value)
1196
+ user = service.create_user(name="Alice")
1197
+
1198
+ assert user.name == "Alice"
1199
+ repo_mock.return_value.save.assert_called_once()
1200
+ ```
1201
+
1202
+ ### Mock Property
1203
+
1204
+ ```python
1205
+ @pytest.fixture
1206
+ def mock_config():
1207
+ """Create a mock with a property."""
1208
+ config = Mock()
1209
+ type(config).debug = PropertyMock(return_value=True)
1210
+ type(config).api_key = PropertyMock(return_value="test-key")
1211
+ return config
1212
+
1213
+ def test_with_mock_config(mock_config):
1214
+ """Test with mocked config properties."""
1215
+ assert mock_config.debug is True
1216
+ assert mock_config.api_key == "test-key"
1217
+ ```
1218
+
1219
+ ## Testing Async Code
1220
+
1221
+ ### Async Tests with pytest-asyncio
1222
+
1223
+ ```python
1224
+ import pytest
1225
+
1226
+ @pytest.mark.asyncio
1227
+ async def test_async_function():
1228
+ """Test async function."""
1229
+ result = await async_add(2, 3)
1230
+ assert result == 5
1231
+
1232
+ @pytest.mark.asyncio
1233
+ async def test_async_with_fixture(async_client):
1234
+ """Test async with async fixture."""
1235
+ response = await async_client.get("/api/users")
1236
+ assert response.status_code == 200
1237
+ ```
1238
+
1239
+ ### Async Fixture
1240
+
1241
+ ```python
1242
+ @pytest.fixture
1243
+ async def async_client():
1244
+ """Async fixture providing async test client."""
1245
+ app = create_app()
1246
+ async with app.test_client() as client:
1247
+ yield client
1248
+
1249
+ @pytest.mark.asyncio
1250
+ async def test_api_endpoint(async_client):
1251
+ """Test using async fixture."""
1252
+ response = await async_client.get("/api/data")
1253
+ assert response.status_code == 200
1254
+ ```
1255
+
1256
+ ### Mocking Async Functions
1257
+
1258
+ ```python
1259
+ @pytest.mark.asyncio
1260
+ @patch("mypackage.async_api_call")
1261
+ async def test_async_mock(api_call_mock):
1262
+ """Test async function with mock."""
1263
+ api_call_mock.return_value = {"status": "ok"}
1264
+
1265
+ result = await my_async_function()
1266
+
1267
+ api_call_mock.assert_awaited_once()
1268
+ assert result["status"] == "ok"
1269
+ ```
1270
+
1271
+ ## Testing Exceptions
1272
+
1273
+ ### Testing Expected Exceptions
1274
+
1275
+ ```python
1276
+ def test_divide_by_zero():
1277
+ """Test that dividing by zero raises ZeroDivisionError."""
1278
+ with pytest.raises(ZeroDivisionError):
1279
+ divide(10, 0)
1280
+
1281
+ def test_custom_exception():
1282
+ """Test custom exception with message."""
1283
+ with pytest.raises(ValueError, match="invalid input"):
1284
+ validate_input("invalid")
1285
+ ```
1286
+
1287
+ ### Testing Exception Attributes
1288
+
1289
+ ```python
1290
+ def test_exception_with_details():
1291
+ """Test exception with custom attributes."""
1292
+ with pytest.raises(CustomError) as exc_info:
1293
+ raise CustomError("error", code=400)
1294
+
1295
+ assert exc_info.value.code == 400
1296
+ assert "error" in str(exc_info.value)
1297
+ ```
1298
+
1299
+ ## Testing Side Effects
1300
+
1301
+ ### Testing File Operations
1302
+
1303
+ ```python
1304
+ import tempfile
1305
+ import os
1306
+
1307
+ def test_file_processing():
1308
+ """Test file processing with temp file."""
1309
+ with tempfile.NamedTemporaryFile(mode='w', delete=False, suffix='.txt') as f:
1310
+ f.write("test content")
1311
+ temp_path = f.name
1312
+
1313
+ try:
1314
+ result = process_file(temp_path)
1315
+ assert result == "processed: test content"
1316
+ finally:
1317
+ os.unlink(temp_path)
1318
+ ```
1319
+
1320
+ ### Testing with pytest's tmp_path Fixture
1321
+
1322
+ ```python
1323
+ def test_with_tmp_path(tmp_path):
1324
+ """Test using pytest's built-in temp path fixture."""
1325
+ test_file = tmp_path / "test.txt"
1326
+ test_file.write_text("hello world")
1327
+
1328
+ result = process_file(str(test_file))
1329
+ assert result == "hello world"
1330
+ # tmp_path automatically cleaned up
1331
+ ```
1332
+
1333
+ ### Testing with tmpdir Fixture
1334
+
1335
+ ```python
1336
+ def test_with_tmpdir(tmpdir):
1337
+ """Test using pytest's tmpdir fixture."""
1338
+ test_file = tmpdir.join("test.txt")
1339
+ test_file.write("data")
1340
+
1341
+ result = process_file(str(test_file))
1342
+ assert result == "data"
1343
+ ```
1344
+
1345
+ ## Test Organization
1346
+
1347
+ ### Directory Structure
1348
+
1349
+ ```
1350
+ tests/
1351
+ ├── conftest.py # Shared fixtures
1352
+ ├── __init__.py
1353
+ ├── unit/ # Unit tests
1354
+ │ ├── __init__.py
1355
+ │ ├── test_models.py
1356
+ │ ├── test_utils.py
1357
+ │ └── test_services.py
1358
+ ├── integration/ # Integration tests
1359
+ │ ├── __init__.py
1360
+ │ ├── test_api.py
1361
+ │ └── test_database.py
1362
+ └── e2e/ # End-to-end tests
1363
+ ├── __init__.py
1364
+ └── test_user_flow.py
1365
+ ```
1366
+
1367
+ ### Test Classes
1368
+
1369
+ ```python
1370
+ class TestUserService:
1371
+ """Group related tests in a class."""
1372
+
1373
+ @pytest.fixture(autouse=True)
1374
+ def setup(self):
1375
+ """Setup runs before each test in this class."""
1376
+ self.service = UserService()
1377
+
1378
+ def test_create_user(self):
1379
+ """Test user creation."""
1380
+ user = self.service.create_user("Alice")
1381
+ assert user.name == "Alice"
1382
+
1383
+ def test_delete_user(self):
1384
+ """Test user deletion."""
1385
+ user = User(id=1, name="Bob")
1386
+ self.service.delete_user(user)
1387
+ assert not self.service.user_exists(1)
1388
+ ```
1389
+
1390
+ ## Best Practices
1391
+
1392
+ ### DO
1393
+
1394
+ - **Follow TDD**: Write tests before code (red-green-refactor)
1395
+ - **Test one thing**: Each test should verify a single behavior
1396
+ - **Use descriptive names**: `test_user_login_with_invalid_credentials_fails`
1397
+ - **Use fixtures**: Eliminate duplication with fixtures
1398
+ - **Mock external dependencies**: Don't depend on external services
1399
+ - **Test edge cases**: Empty inputs, None values, boundary conditions
1400
+ - **Aim for 80%+ coverage**: Focus on critical paths
1401
+ - **Keep tests fast**: Use marks to separate slow tests
1402
+
1403
+ ### DON'T
1404
+
1405
+ - **Don't test implementation**: Test behavior, not internals
1406
+ - **Don't use complex conditionals in tests**: Keep tests simple
1407
+ - **Don't ignore test failures**: All tests must pass
1408
+ - **Don't test third-party code**: Trust libraries to work
1409
+ - **Don't share state between tests**: Tests should be independent
1410
+ - **Don't catch exceptions in tests**: Use `pytest.raises`
1411
+ - **Don't use print statements**: Use assertions and pytest output
1412
+ - **Don't write tests that are too brittle**: Avoid over-specific mocks
1413
+
1414
+ ## Common Patterns
1415
+
1416
+ ### Testing API Endpoints (FastAPI/Flask)
1417
+
1418
+ ```python
1419
+ @pytest.fixture
1420
+ def client():
1421
+ app = create_app(testing=True)
1422
+ return app.test_client()
1423
+
1424
+ def test_get_user(client):
1425
+ response = client.get("/api/users/1")
1426
+ assert response.status_code == 200
1427
+ assert response.json["id"] == 1
1428
+
1429
+ def test_create_user(client):
1430
+ response = client.post("/api/users", json={
1431
+ "name": "Alice",
1432
+ "email": "alice@example.com"
1433
+ })
1434
+ assert response.status_code == 201
1435
+ assert response.json["name"] == "Alice"
1436
+ ```
1437
+
1438
+ ### Testing Database Operations
1439
+
1440
+ ```python
1441
+ @pytest.fixture
1442
+ def db_session():
1443
+ """Create a test database session."""
1444
+ session = Session(bind=engine)
1445
+ session.begin_nested()
1446
+ yield session
1447
+ session.rollback()
1448
+ session.close()
1449
+
1450
+ def test_create_user(db_session):
1451
+ user = User(name="Alice", email="alice@example.com")
1452
+ db_session.add(user)
1453
+ db_session.commit()
1454
+
1455
+ retrieved = db_session.query(User).filter_by(name="Alice").first()
1456
+ assert retrieved.email == "alice@example.com"
1457
+ ```
1458
+
1459
+ ### Testing Class Methods
1460
+
1461
+ ```python
1462
+ class TestCalculator:
1463
+ @pytest.fixture
1464
+ def calculator(self):
1465
+ return Calculator()
1466
+
1467
+ def test_add(self, calculator):
1468
+ assert calculator.add(2, 3) == 5
1469
+
1470
+ def test_divide_by_zero(self, calculator):
1471
+ with pytest.raises(ZeroDivisionError):
1472
+ calculator.divide(10, 0)
1473
+ ```
1474
+
1475
+ ## pytest Configuration
1476
+
1477
+ ### pytest.ini
1478
+
1479
+ ```ini
1480
+ [pytest]
1481
+ testpaths = tests
1482
+ python_files = test_*.py
1483
+ python_classes = Test*
1484
+ python_functions = test_*
1485
+ addopts =
1486
+ --strict-markers
1487
+ --disable-warnings
1488
+ --cov=mypackage
1489
+ --cov-report=term-missing
1490
+ --cov-report=html
1491
+ markers =
1492
+ slow: marks tests as slow
1493
+ integration: marks tests as integration tests
1494
+ unit: marks tests as unit tests
1495
+ ```
1496
+
1497
+ ### pyproject.toml
1498
+
1499
+ ```toml
1500
+ [tool.pytest.ini_options]
1501
+ testpaths = ["tests"]
1502
+ python_files = ["test_*.py"]
1503
+ python_classes = ["Test*"]
1504
+ python_functions = ["test_*"]
1505
+ addopts = [
1506
+ "--strict-markers",
1507
+ "--cov=mypackage",
1508
+ "--cov-report=term-missing",
1509
+ "--cov-report=html",
1510
+ ]
1511
+ markers = [
1512
+ "slow: marks tests as slow",
1513
+ "integration: marks tests as integration tests",
1514
+ "unit: marks tests as unit tests",
1515
+ ]
1516
+ ```
1517
+
1518
+ ## Running Tests
1519
+
1520
+ ```bash
1521
+ # Run all tests
1522
+ pytest
1523
+
1524
+ # Run specific file
1525
+ pytest tests/test_utils.py
1526
+
1527
+ # Run specific test
1528
+ pytest tests/test_utils.py::test_function
1529
+
1530
+ # Run with verbose output
1531
+ pytest -v
1532
+
1533
+ # Run with coverage
1534
+ pytest --cov=mypackage --cov-report=html
1535
+
1536
+ # Run only fast tests
1537
+ pytest -m "not slow"
1538
+
1539
+ # Run until first failure
1540
+ pytest -x
1541
+
1542
+ # Run and stop on N failures
1543
+ pytest --maxfail=3
1544
+
1545
+ # Run last failed tests
1546
+ pytest --lf
1547
+
1548
+ # Run tests with pattern
1549
+ pytest -k "test_user"
1550
+
1551
+ # Run with debugger on failure
1552
+ pytest --pdb
1553
+ ```
1554
+
1555
+ ## Quick Reference
1556
+
1557
+ | Pattern | Usage |
1558
+ |---------|-------|
1559
+ | `pytest.raises()` | Test expected exceptions |
1560
+ | `@pytest.fixture()` | Create reusable test fixtures |
1561
+ | `@pytest.mark.parametrize()` | Run tests with multiple inputs |
1562
+ | `@pytest.mark.slow` | Mark slow tests |
1563
+ | `pytest -m "not slow"` | Skip slow tests |
1564
+ | `@patch()` | Mock functions and classes |
1565
+ | `tmp_path` fixture | Automatic temp directory |
1566
+ | `pytest --cov` | Generate coverage report |
1567
+ | `assert` | Simple and readable assertions |
1568
+
1569
+ **Remember**: Tests are code too. Keep them clean, readable, and maintainable. Good tests catch bugs; great tests prevent them.
1570
+
1571
+ ### fastapi-patterns
1572
+
1573
+ FastAPI project layout, app factory/lifespan, pydantic-settings config, Pydantic v2 request/response schemas, async dependency injection, router design, a transactional service layer, and httpx+pytest testing. Use when building or reviewing a FastAPI application.
1574
+
1575
+ # FastAPI Patterns
1576
+
1577
+ Modern, production-grade FastAPI development: project layout, Pydantic v2 schemas, dependency injection, async patterns, auth, transactional service methods, and testing.
1578
+
1579
+ ## Project Structure
1580
+
1581
+ ```text
1582
+ my_app/
1583
+ |-- app/
1584
+ | |-- main.py # App factory, lifespan, middleware
1585
+ | |-- config.py # Settings via pydantic-settings
1586
+ | |-- dependencies.py # Shared FastAPI dependencies
1587
+ | |-- database.py # SQLAlchemy engine + session
1588
+ | |-- routers/
1589
+ | | `-- users.py
1590
+ | |-- models/ # SQLAlchemy ORM models
1591
+ | | `-- user.py
1592
+ | |-- schemas/ # Pydantic request/response schemas
1593
+ | | `-- user.py
1594
+ | `-- services/ # Business logic layer
1595
+ | `-- user_service.py
1596
+ |-- tests/
1597
+ | |-- conftest.py
1598
+ | `-- test_users.py
1599
+ |-- pyproject.toml
1600
+ `-- .env
1601
+ ```
1602
+
1603
+ ---
1604
+
1605
+ ## App Factory and Lifespan
1606
+
1607
+ ```python
1608
+ # app/main.py
1609
+ from contextlib import asynccontextmanager
1610
+ from fastapi import FastAPI
1611
+ from fastapi.middleware.cors import CORSMiddleware
1612
+
1613
+ from app.config import settings
1614
+ from app.database import engine, Base
1615
+ from app.routers import users
1616
+
1617
+
1618
+ @asynccontextmanager
1619
+ async def lifespan(app: FastAPI):
1620
+ # Automatically create tables on startup for ease of use in dev/demo environments.
1621
+ # For strict production applications, manage schemas via Alembic migrations instead.
1622
+ async with engine.begin() as conn:
1623
+ await conn.run_sync(Base.metadata.create_all)
1624
+ yield
1625
+ # Shutdown: close pooled resources.
1626
+ await engine.dispose()
1627
+
1628
+
1629
+ def create_app() -> FastAPI:
1630
+ app = FastAPI(
1631
+ title=settings.app_name,
1632
+ version=settings.app_version,
1633
+ lifespan=lifespan,
1634
+ )
1635
+
1636
+ app.add_middleware(
1637
+ CORSMiddleware,
1638
+ allow_origins=settings.allowed_origins,
1639
+ allow_credentials=settings.allow_credentials,
1640
+ allow_methods=settings.allowed_methods,
1641
+ allow_headers=settings.allowed_headers,
1642
+ )
1643
+
1644
+ app.include_router(users.router, prefix="/users", tags=["users"])
1645
+
1646
+ return app
1647
+
1648
+
1649
+ app = create_app()
1650
+ ```
1651
+
1652
+ ---
1653
+
1654
+ ## Configuration with pydantic-settings
1655
+
1656
+ ```python
1657
+ # app/config.py
1658
+ from pydantic_settings import BaseSettings, SettingsConfigDict
1659
+
1660
+
1661
+ class Settings(BaseSettings):
1662
+ model_config = SettingsConfigDict(env_file=".env", env_file_encoding="utf-8")
1663
+
1664
+ app_name: str = "My App"
1665
+ app_version: str = "0.1.0"
1666
+ debug: bool = False
1667
+
1668
+ database_url: str
1669
+ secret_key: str
1670
+ algorithm: str = "HS256"
1671
+ access_token_expire_minutes: int = 30
1672
+
1673
+ # Pydantic-settings v2 safely evaluates mutable list literals directly
1674
+ allowed_origins: list[str] = ["http://localhost:3000"]
1675
+ allowed_methods: list[str] = ["GET", "POST", "PATCH", "DELETE", "OPTIONS"]
1676
+ allowed_headers: list[str] = ["Authorization", "Content-Type"]
1677
+ allow_credentials: bool = True
1678
+
1679
+
1680
+ settings = Settings()
1681
+ ```
1682
+
1683
+ ---
1684
+
1685
+ ## Pydantic Schemas (v2)
1686
+
1687
+ ```python
1688
+ # app/schemas/user.py
1689
+ from datetime import datetime
1690
+ from pydantic import BaseModel, EmailStr, Field, model_validator
1691
+
1692
+
1693
+ class UserBase(BaseModel):
1694
+ email: EmailStr
1695
+ username: str = Field(min_length=3, max_length=50)
1696
+
1697
+
1698
+ class UserCreate(UserBase):
1699
+ password: str = Field(min_length=8)
1700
+ password_confirm: str
1701
+
1702
+ @model_validator(mode="after")
1703
+ def passwords_match(self) -> "UserCreate":
1704
+ if self.password != self.password_confirm:
1705
+ raise ValueError("Passwords do not match")
1706
+ return self
1707
+
1708
+
1709
+ class UserUpdate(BaseModel):
1710
+ username: str | None = Field(default=None, min_length=3, max_length=50)
1711
+ email: EmailStr | None = None
1712
+
1713
+
1714
+ class UserResponse(UserBase):
1715
+ id: int
1716
+ is_active: bool
1717
+ created_at: datetime
1718
+
1719
+ model_config = {"from_attributes": True}
1720
+
1721
+
1722
+ class UserListResponse(BaseModel):
1723
+ total: int
1724
+ items: list[UserResponse]
1725
+ ```
1726
+
1727
+ ---
1728
+
1729
+ ## Dependency Injection
1730
+
1731
+ ```python
1732
+ # app/dependencies.py
1733
+ from typing import Annotated, AsyncGenerator
1734
+ from fastapi import Depends, HTTPException, status
1735
+ from fastapi.security import OAuth2PasswordBearer
1736
+ from jose import JWTError, jwt
1737
+ from sqlalchemy.ext.asyncio import AsyncSession
1738
+
1739
+ from app.config import settings
1740
+ from app.database import AsyncSessionLocal
1741
+ from app.models.user import User
1742
+
1743
+ oauth2_scheme = OAuth2PasswordBearer(tokenUrl="/users/token")
1744
+
1745
+
1746
+ async def get_db() -> AsyncGenerator[AsyncSession, None]:
1747
+ async with AsyncSessionLocal() as session:
1748
+ try:
1749
+ yield session
1750
+ except Exception:
1751
+ await session.rollback()
1752
+ raise
1753
+
1754
+
1755
+ async def get_current_user(
1756
+ token: Annotated[str, Depends(oauth2_scheme)],
1757
+ db: Annotated[AsyncSession, Depends(get_db)],
1758
+ ) -> User:
1759
+ credentials_exception = HTTPException(
1760
+ status_code=status.HTTP_401_UNAUTHORIZED,
1761
+ detail="Could not validate credentials",
1762
+ headers={"WWW-Authenticate": "Bearer"},
1763
+ )
1764
+ try:
1765
+ payload = jwt.decode(token, settings.secret_key, algorithms=[settings.algorithm])
1766
+ subject = payload.get("sub")
1767
+ if subject is None:
1768
+ raise credentials_exception
1769
+ user_id = int(subject)
1770
+ except (JWTError, TypeError, ValueError):
1771
+ raise credentials_exception
1772
+
1773
+ user = await db.get(User, user_id)
1774
+ if user is None:
1775
+ raise credentials_exception
1776
+ return user
1777
+
1778
+
1779
+ async def get_current_active_user(
1780
+ current_user: Annotated[User, Depends(get_current_user)],
1781
+ ) -> User:
1782
+ if not current_user.is_active:
1783
+ raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Inactive user")
1784
+ return current_user
1785
+
1786
+
1787
+ DbDep = Annotated[AsyncSession, Depends(get_db)]
1788
+ CurrentUserDep = Annotated[User, Depends(get_current_user)]
1789
+ ActiveUserDep = Annotated[User, Depends(get_current_active_user)]
1790
+ ```
1791
+
1792
+ ---
1793
+
1794
+ ## Router and Endpoint Design
1795
+
1796
+ ```python
1797
+ # app/routers/users.py
1798
+ from typing import Annotated
1799
+ from fastapi import APIRouter, HTTPException, Query, status
1800
+ from fastapi.security import OAuth2PasswordRequestForm
1801
+
1802
+ from app.dependencies import ActiveUserDep, DbDep
1803
+ from app.schemas.user import UserCreate, UserResponse, UserUpdate, UserListResponse
1804
+ from app.services.user_service import DuplicateUserError, UserService
1805
+
1806
+ router = APIRouter()
1807
+
1808
+
1809
+ @router.post("/", response_model=UserResponse, status_code=status.HTTP_201_CREATED)
1810
+ async def create_user(payload: UserCreate, db: DbDep) -> UserResponse:
1811
+ service = UserService(db)
1812
+ try:
1813
+ return await service.create(payload)
1814
+ except DuplicateUserError:
1815
+ raise HTTPException(status_code=400, detail="Email already registered")
1816
+
1817
+
1818
+ @router.get("/me", response_model=UserResponse)
1819
+ async def get_me(current_user: ActiveUserDep) -> UserResponse:
1820
+ return current_user
1821
+
1822
+
1823
+ @router.get("/", response_model=UserListResponse)
1824
+ async def list_users(
1825
+ db: DbDep,
1826
+ current_user: ActiveUserDep,
1827
+ skip: Annotated[int, Query(ge=0)] = 0,
1828
+ limit: Annotated[int, Query(ge=1, le=100)] = 20,
1829
+ ) -> UserListResponse:
1830
+ service = UserService(db)
1831
+ users, total = await service.list(skip=skip, limit=limit)
1832
+ return UserListResponse(total=total, items=users)
1833
+
1834
+
1835
+ @router.patch("/{user_id}", response_model=UserResponse)
1836
+ async def update_user(
1837
+ user_id: int,
1838
+ payload: UserUpdate,
1839
+ db: DbDep,
1840
+ current_user: ActiveUserDep,
1841
+ ) -> UserResponse:
1842
+ if current_user.id != user_id:
1843
+ raise HTTPException(status_code=403, detail="Not authorized")
1844
+ service = UserService(db)
1845
+ try:
1846
+ user = await service.update(user_id, payload)
1847
+ except DuplicateUserError:
1848
+ raise HTTPException(status_code=400, detail="Email already registered")
1849
+ if user is None:
1850
+ raise HTTPException(status_code=404, detail="User not found")
1851
+ return user
1852
+
1853
+
1854
+ @router.post("/token")
1855
+ async def login(
1856
+ form_data: Annotated[OAuth2PasswordRequestForm, Depends()],
1857
+ db: DbDep,
1858
+ ) -> dict[str, str]:
1859
+ service = UserService(db)
1860
+ token = await service.authenticate(form_data.username, form_data.password)
1861
+ if token is None:
1862
+ raise HTTPException(
1863
+ status_code=status.HTTP_401_UNAUTHORIZED,
1864
+ detail="Incorrect username or password",
1865
+ headers={"WWW-Authenticate": "Bearer"},
1866
+ )
1867
+ return {"access_token": token, "token_type": "bearer"}
1868
+ ```
1869
+
1870
+ ---
1871
+
1872
+ ## Service Layer
1873
+
1874
+ ```python
1875
+ # app/services/user_service.py
1876
+ from datetime import datetime, timedelta, timezone
1877
+
1878
+ from jose import jwt
1879
+ from passlib.context import CryptContext
1880
+ from sqlalchemy import func, select
1881
+ from sqlalchemy.exc import IntegrityError
1882
+ from sqlalchemy.ext.asyncio import AsyncSession
1883
+
1884
+ from app.config import settings
1885
+ from app.models.user import User
1886
+ from app.schemas.user import UserCreate, UserUpdate
1887
+
1888
+ pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
1889
+
1890
+
1891
+ class DuplicateUserError(Exception):
1892
+ """Raised when a unique user field conflicts with an existing row."""
1893
+
1894
+
1895
+ class UserService:
1896
+ def __init__(self, db: AsyncSession) -> None:
1897
+ self.db = db
1898
+
1899
+ async def get_by_email(self, email: str) -> User | None:
1900
+ result = await self.db.execute(select(User).where(User.email == email))
1901
+ return result.scalar_one_or_none()
1902
+
1903
+ async def create(self, payload: UserCreate) -> User:
1904
+ user = User(
1905
+ email=payload.email,
1906
+ username=payload.username,
1907
+ hashed_password=pwd_context.hash(payload.password),
1908
+ )
1909
+ self.db.add(user)
1910
+ try:
1911
+ # Rely on atomic DB constraints rather than race-prone application-level prechecks
1912
+ await self.db.commit()
1913
+ except IntegrityError as exc:
1914
+ await self.db.rollback()
1915
+ raise DuplicateUserError from exc
1916
+ await self.db.refresh(user)
1917
+ return user
1918
+
1919
+ async def list(self, skip: int = 0, limit: int = 20) -> tuple[list[User], int]:
1920
+ total_result = await self.db.execute(select(func.count(User.id)))
1921
+ total = total_result.scalar_one()
1922
+ # Enforce explicit deterministic ordering to ensure reliable pagination
1923
+ result = await self.db.execute(
1924
+ select(User).order_by(User.id).offset(skip).limit(limit)
1925
+ )
1926
+ return list(result.scalars()), total
1927
+
1928
+ async def update(self, user_id: int, payload: UserUpdate) -> User | None:
1929
+ user = await self.db.get(User, user_id)
1930
+ if user is None:
1931
+ return None
1932
+ for field, value in payload.model_dump(exclude_unset=True).items():
1933
+ setattr(user, field, value)
1934
+ try:
1935
+ await self.db.commit()
1936
+ except IntegrityError as exc:
1937
+ await self.db.rollback()
1938
+ raise DuplicateUserError from exc
1939
+ await self.db.refresh(user)
1940
+ return user
1941
+
1942
+ async def authenticate(self, email: str, password: str) -> str | None:
1943
+ user = await self.get_by_email(email)
1944
+ if user is None or not pwd_context.verify(password, user.hashed_password):
1945
+ return None
1946
+ expire = datetime.now(timezone.utc) + timedelta(
1947
+ minutes=settings.access_token_expire_minutes
1948
+ )
1949
+ return jwt.encode(
1950
+ {"sub": str(user.id), "exp": expire},
1951
+ settings.secret_key,
1952
+ algorithm=settings.algorithm,
1953
+ )
1954
+ ```
1955
+
1956
+ > **Note on Database Design:** Application-level unique handling requires an underlying unique database index (e.g., `unique=True` on your SQLAlchemy mapping attributes). Without underlying constraints, application layer error-catching cannot safely prevent concurrent race conditions.
1957
+
1958
+ ---
1959
+
1960
+ ## Testing with httpx and pytest
1961
+
1962
+ ```python
1963
+ # tests/conftest.py
1964
+ import pytest_asyncio
1965
+ from httpx import ASGITransport, AsyncClient
1966
+ from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
1967
+
1968
+ from app.database import Base
1969
+ from app.dependencies import get_db
1970
+ from app.main import create_app
1971
+
1972
+ TEST_DATABASE_URL = "sqlite+aiosqlite:///:memory:"
1973
+
1974
+ engine = create_async_engine(TEST_DATABASE_URL)
1975
+ TestingSessionLocal = async_sessionmaker(engine, expire_on_commit=False)
1976
+
1977
+
1978
+ @pytest_asyncio.fixture(autouse=True)
1979
+ async def setup_db():
1980
+ async with engine.begin() as conn:
1981
+ await conn.run_sync(Base.metadata.create_all)
1982
+ yield
1983
+ async with engine.begin() as conn:
1984
+ await conn.run_sync(Base.metadata.drop_all)
1985
+
1986
+
1987
+ @pytest_asyncio.fixture
1988
+ async def db_session():
1989
+ async with TestingSessionLocal() as session:
1990
+ yield session
1991
+ await session.rollback()
1992
+
1993
+
1994
+ @pytest_asyncio.fixture
1995
+ async def client(db_session: AsyncSession):
1996
+ app = create_app()
1997
+
1998
+ async def override_get_db():
1999
+ yield db_session
2000
+
2001
+ app.dependency_overrides[get_db] = override_get_db
2002
+
2003
+ async with AsyncClient(
2004
+ transport=ASGITransport(app=app), base_url="http://test"
2005
+ ) as ac:
2006
+ yield ac
2007
+
2008
+
2009
+ @pytest_asyncio.fixture
2010
+ async def registered_user(client: AsyncClient) -> dict:
2011
+ resp = await client.post("/users/", json={
2012
+ "email": "test@example.com",
2013
+ "username": "testuser",
2014
+ "password": "securepass1",
2015
+ "password_confirm": "securepass1",
2016
+ })
2017
+ assert resp.status_code == 201
2018
+ return resp.json()
2019
+
2020
+
2021
+ @pytest_asyncio.fixture
2022
+ async def auth_token(client: AsyncClient, registered_user: dict) -> str:
2023
+ resp = await client.post("/users/token", data={
2024
+ "username": "test@example.com",
2025
+ "password": "securepass1",
2026
+ })
2027
+ assert resp.status_code == 200
2028
+ return resp.json()["access_token"]
2029
+
2030
+
2031
+ @pytest_asyncio.fixture
2032
+ async def auth_client(client: AsyncClient, auth_token: str) -> AsyncClient:
2033
+ client.headers.update({"Authorization": f"Bearer {auth_token}"})
2034
+ return client
2035
+ ```
2036
+
2037
+ ---
2038
+
2039
+ ## Anti-Patterns
2040
+
2041
+ ```python
2042
+ # Bad: business logic inside route handlers.
2043
+ @router.post("/users/")
2044
+ async def create_user(payload: UserCreate, db: DbDep):
2045
+ hashed = bcrypt.hash(payload.password)
2046
+ user = User(email=payload.email, hashed_password=hashed)
2047
+ db.add(user)
2048
+ await db.commit()
2049
+ return user
2050
+
2051
+ # Good: thin route, transactional service handling.
2052
+ @router.post("/users/", response_model=UserResponse, status_code=201)
2053
+ async def create_user(payload: UserCreate, db: DbDep):
2054
+ try:
2055
+ return await UserService(db).create(payload)
2056
+ except DuplicateUserError:
2057
+ raise HTTPException(status_code=400, detail="Email already registered")
2058
+
2059
+
2060
+ # Bad: sync DB calls in async routes block the event loop.
2061
+ @router.get("/items/")
2062
+ async def list_items(db: Session = Depends(get_db)):
2063
+ return db.query(Item).all()
2064
+
2065
+ # Good: use async SQLAlchemy executions.
2066
+ @router.get("/items/")
2067
+ async def list_items(db: AsyncSession = Depends(get_db)):
2068
+ result = await db.execute(select(Item))
2069
+ return result.scalars().all()
2070
+ ```
2071
+
2072
+ ---
2073
+
2074
+ ## Best Practices
2075
+
2076
+ - Always declare a typed `response_model` to prevent accidental PII/data leaks and output clean OpenAPI schemas.
2077
+ - Consolidate standard middleware dependency injections via type-aliasing: `DbDep = Annotated[AsyncSession, Depends(get_db)]`.
2078
+ - Wrap database mutation boundaries gracefully within transactions inside your service layer, catching structural database errors directly.
2079
+ - Parse JWT parameters defensively, expecting potential string/integer cast mismatches from modern payload variations.
2080
+ - Enforce deterministic sorting (e.g., `.order_by(Model.id)`) on all offset/limit paginated endpoints to avoid data skips.
2081
+ - Isolate authorization checks from core authentication dependencies to provide precise REST status signals (`401` vs `403`).
2082
+
2083
+ ### django-security
2084
+
2085
+ Django production security settings, authentication and custom user models, authorization/RBAC and DRF permissions, SQL injection and XSS prevention, CSRF, file-upload validation, rate limiting, security headers, and secret management. Use when configuring or reviewing Django security posture.
2086
+
2087
+ # Django Security Best Practices
2088
+
2089
+ Comprehensive security guidelines for Django applications to protect against common vulnerabilities.
2090
+
2091
+ ## When to Activate
2092
+
2093
+ - Setting up Django authentication and authorization
2094
+ - Implementing user permissions and roles
2095
+ - Configuring production security settings
2096
+ - Reviewing Django application for security issues
2097
+ - Deploying Django applications to production
2098
+
2099
+ ## Core Security Settings
2100
+
2101
+ ### Production Settings Configuration
2102
+
2103
+ ```python
2104
+ # settings/production.py
2105
+ import os
2106
+
2107
+ DEBUG = False # CRITICAL: Never use True in production
2108
+
2109
+ ALLOWED_HOSTS = os.environ.get('ALLOWED_HOSTS', '').split(',')
2110
+
2111
+ # Security headers
2112
+ SECURE_SSL_REDIRECT = True
2113
+ SESSION_COOKIE_SECURE = True
2114
+ CSRF_COOKIE_SECURE = True
2115
+ SECURE_HSTS_SECONDS = 31536000 # 1 year
2116
+ SECURE_HSTS_INCLUDE_SUBDOMAINS = True
2117
+ SECURE_HSTS_PRELOAD = True
2118
+ SECURE_CONTENT_TYPE_NOSNIFF = True
2119
+ SECURE_BROWSER_XSS_FILTER = True
2120
+ X_FRAME_OPTIONS = 'DENY'
2121
+
2122
+ # HTTPS and Cookies
2123
+ SESSION_COOKIE_HTTPONLY = True
2124
+ CSRF_COOKIE_HTTPONLY = True
2125
+ SESSION_COOKIE_SAMESITE = 'Lax'
2126
+ CSRF_COOKIE_SAMESITE = 'Lax'
2127
+
2128
+ # Secret key (must be set via environment variable)
2129
+ SECRET_KEY = os.environ.get('DJANGO_SECRET_KEY')
2130
+ if not SECRET_KEY:
2131
+ raise ImproperlyConfigured('DJANGO_SECRET_KEY environment variable is required')
2132
+
2133
+ # Password validation
2134
+ AUTH_PASSWORD_VALIDATORS = [
2135
+ {
2136
+ 'NAME': 'django.contrib.auth.password_validation.UserAttributeSimilarityValidator',
2137
+ },
2138
+ {
2139
+ 'NAME': 'django.contrib.auth.password_validation.MinimumLengthValidator',
2140
+ 'OPTIONS': {
2141
+ 'min_length': 12,
2142
+ }
2143
+ },
2144
+ {
2145
+ 'NAME': 'django.contrib.auth.password_validation.CommonPasswordValidator',
2146
+ },
2147
+ {
2148
+ 'NAME': 'django.contrib.auth.password_validation.NumericPasswordValidator',
2149
+ },
2150
+ ]
2151
+ ```
2152
+
2153
+ ## Authentication
2154
+
2155
+ ### Custom User Model
2156
+
2157
+ ```python
2158
+ # apps/users/models.py
2159
+ from django.contrib.auth.models import AbstractUser
2160
+ from django.db import models
2161
+
2162
+ class User(AbstractUser):
2163
+ """Custom user model for better security."""
2164
+
2165
+ email = models.EmailField(unique=True)
2166
+ phone = models.CharField(max_length=20, blank=True)
2167
+
2168
+ USERNAME_FIELD = 'email' # Use email as username
2169
+ REQUIRED_FIELDS = ['username']
2170
+
2171
+ class Meta:
2172
+ db_table = 'users'
2173
+ verbose_name = 'User'
2174
+ verbose_name_plural = 'Users'
2175
+
2176
+ def __str__(self):
2177
+ return self.email
2178
+
2179
+ # settings/base.py
2180
+ AUTH_USER_MODEL = 'users.User'
2181
+ ```
2182
+
2183
+ ### Password Hashing
2184
+
2185
+ ```python
2186
+ # Django uses PBKDF2 by default. For stronger security:
2187
+ PASSWORD_HASHERS = [
2188
+ 'django.contrib.auth.hashers.Argon2PasswordHasher',
2189
+ 'django.contrib.auth.hashers.PBKDF2PasswordHasher',
2190
+ 'django.contrib.auth.hashers.PBKDF2SHA1PasswordHasher',
2191
+ 'django.contrib.auth.hashers.BCryptSHA256PasswordHasher',
2192
+ ]
2193
+ ```
2194
+
2195
+ ### Session Management
2196
+
2197
+ ```python
2198
+ # Session configuration
2199
+ SESSION_ENGINE = 'django.contrib.sessions.backends.cache' # Or 'db'
2200
+ SESSION_CACHE_ALIAS = 'default'
2201
+ SESSION_COOKIE_AGE = 3600 * 24 * 7 # 1 week
2202
+ SESSION_SAVE_EVERY_REQUEST = False
2203
+ SESSION_EXPIRE_AT_BROWSER_CLOSE = False # Better UX, but less secure
2204
+ ```
2205
+
2206
+ ## Authorization
2207
+
2208
+ ### Permissions
2209
+
2210
+ ```python
2211
+ # models.py
2212
+ from django.db import models
2213
+ from django.contrib.auth.models import Permission
2214
+
2215
+ class Post(models.Model):
2216
+ title = models.CharField(max_length=200)
2217
+ content = models.TextField()
2218
+ author = models.ForeignKey(User, on_delete=models.CASCADE)
2219
+
2220
+ class Meta:
2221
+ permissions = [
2222
+ ('can_publish', 'Can publish posts'),
2223
+ ('can_edit_others', 'Can edit posts of others'),
2224
+ ]
2225
+
2226
+ def user_can_edit(self, user):
2227
+ """Check if user can edit this post."""
2228
+ return self.author == user or user.has_perm('app.can_edit_others')
2229
+
2230
+ # views.py
2231
+ from django.contrib.auth.mixins import LoginRequiredMixin, PermissionRequiredMixin
2232
+ from django.views.generic import UpdateView
2233
+
2234
+ class PostUpdateView(LoginRequiredMixin, PermissionRequiredMixin, UpdateView):
2235
+ model = Post
2236
+ permission_required = 'app.can_edit_others'
2237
+ raise_exception = True # Return 403 instead of redirect
2238
+
2239
+ def get_queryset(self):
2240
+ """Only allow users to edit their own posts."""
2241
+ return Post.objects.filter(author=self.request.user)
2242
+ ```
2243
+
2244
+ ### Custom Permissions
2245
+
2246
+ ```python
2247
+ # permissions.py
2248
+ from rest_framework import permissions
2249
+
2250
+ class IsOwnerOrReadOnly(permissions.BasePermission):
2251
+ """Allow only owners to edit objects."""
2252
+
2253
+ def has_object_permission(self, request, view, obj):
2254
+ # Read permissions allowed for any request
2255
+ if request.method in permissions.SAFE_METHODS:
2256
+ return True
2257
+
2258
+ # Write permissions only for owner
2259
+ return obj.author == request.user
2260
+
2261
+ class IsAdminOrReadOnly(permissions.BasePermission):
2262
+ """Allow admins to do anything, others read-only."""
2263
+
2264
+ def has_permission(self, request, view):
2265
+ if request.method in permissions.SAFE_METHODS:
2266
+ return True
2267
+ return request.user and request.user.is_staff
2268
+
2269
+ class IsVerifiedUser(permissions.BasePermission):
2270
+ """Allow only verified users."""
2271
+
2272
+ def has_permission(self, request, view):
2273
+ return request.user and request.user.is_authenticated and request.user.is_verified
2274
+ ```
2275
+
2276
+ ### Role-Based Access Control (RBAC)
2277
+
2278
+ ```python
2279
+ # models.py
2280
+ from django.contrib.auth.models import AbstractUser, Group
2281
+
2282
+ class User(AbstractUser):
2283
+ ROLE_CHOICES = [
2284
+ ('admin', 'Administrator'),
2285
+ ('moderator', 'Moderator'),
2286
+ ('user', 'Regular User'),
2287
+ ]
2288
+ role = models.CharField(max_length=20, choices=ROLE_CHOICES, default='user')
2289
+
2290
+ def is_admin(self):
2291
+ return self.role == 'admin' or self.is_superuser
2292
+
2293
+ def is_moderator(self):
2294
+ return self.role in ['admin', 'moderator']
2295
+
2296
+ # Mixins
2297
+ class AdminRequiredMixin:
2298
+ """Mixin to require admin role."""
2299
+
2300
+ def dispatch(self, request, *args, **kwargs):
2301
+ if not request.user.is_authenticated or not request.user.is_admin():
2302
+ from django.core.exceptions import PermissionDenied
2303
+ raise PermissionDenied
2304
+ return super().dispatch(request, *args, **kwargs)
2305
+ ```
2306
+
2307
+ ## SQL Injection Prevention
2308
+
2309
+ ### Django ORM Protection
2310
+
2311
+ ```python
2312
+ # GOOD: Django ORM automatically escapes parameters
2313
+ def get_user(username):
2314
+ return User.objects.get(username=username) # Safe
2315
+
2316
+ # GOOD: Using parameters with raw()
2317
+ def search_users(query):
2318
+ return User.objects.raw('SELECT * FROM users WHERE username = %s', [query])
2319
+
2320
+ # BAD: Never directly interpolate user input
2321
+ def get_user_bad(username):
2322
+ return User.objects.raw(f'SELECT * FROM users WHERE username = {username}') # VULNERABLE!
2323
+
2324
+ # GOOD: Using filter with proper escaping
2325
+ def get_users_by_email(email):
2326
+ return User.objects.filter(email__iexact=email) # Safe
2327
+
2328
+ # GOOD: Using Q objects for complex queries
2329
+ from django.db.models import Q
2330
+ def search_users_complex(query):
2331
+ return User.objects.filter(
2332
+ Q(username__icontains=query) |
2333
+ Q(email__icontains=query)
2334
+ ) # Safe
2335
+ ```
2336
+
2337
+ ### Extra Security with raw()
2338
+
2339
+ ```python
2340
+ # If you must use raw SQL, always use parameters
2341
+ User.objects.raw(
2342
+ 'SELECT * FROM users WHERE email = %s AND status = %s',
2343
+ [user_input_email, status]
2344
+ )
2345
+ ```
2346
+
2347
+ ## XSS Prevention
2348
+
2349
+ ### Template Escaping
2350
+
2351
+ ```django
2352
+ {# Django auto-escapes variables by default - SAFE #}
2353
+ {{ user_input }} {# Escaped HTML #}
2354
+
2355
+ {# Explicitly mark safe only for trusted content #}
2356
+ {{ trusted_html|safe }} {# Not escaped #}
2357
+
2358
+ {# Use template filters for safe HTML #}
2359
+ {{ user_input|escape }} {# Same as default #}
2360
+ {{ user_input|striptags }} {# Remove all HTML tags #}
2361
+
2362
+ {# JavaScript escaping #}
2363
+ <script>
2364
+ var username = {{ username|escapejs }};
2365
+ </script>
2366
+ ```
2367
+
2368
+ ### Safe String Handling
2369
+
2370
+ ```python
2371
+ from django.utils.safestring import mark_safe
2372
+ from django.utils.html import escape
2373
+
2374
+ # BAD: Never mark user input as safe without escaping
2375
+ def render_bad(user_input):
2376
+ return mark_safe(user_input) # VULNERABLE!
2377
+
2378
+ # GOOD: Escape first, then mark safe
2379
+ def render_good(user_input):
2380
+ return mark_safe(escape(user_input))
2381
+
2382
+ # GOOD: Use format_html for HTML with variables
2383
+ from django.utils.html import format_html
2384
+
2385
+ def greet_user(username):
2386
+ return format_html('<span class="user">{}</span>', escape(username))
2387
+ ```
2388
+
2389
+ ### HTTP Headers
2390
+
2391
+ ```python
2392
+ # settings.py
2393
+ SECURE_CONTENT_TYPE_NOSNIFF = True # Prevent MIME sniffing
2394
+ SECURE_BROWSER_XSS_FILTER = True # Enable XSS filter
2395
+ X_FRAME_OPTIONS = 'DENY' # Prevent clickjacking
2396
+
2397
+ # Custom middleware
2398
+ from django.conf import settings
2399
+
2400
+ class SecurityHeaderMiddleware:
2401
+ def __init__(self, get_response):
2402
+ self.get_response = get_response
2403
+
2404
+ def __call__(self, request):
2405
+ response = self.get_response(request)
2406
+ response['X-Content-Type-Options'] = 'nosniff'
2407
+ response['X-Frame-Options'] = 'DENY'
2408
+ response['X-XSS-Protection'] = '1; mode=block'
2409
+ response['Content-Security-Policy'] = "default-src 'self'"
2410
+ return response
2411
+ ```
2412
+
2413
+ ## CSRF Protection
2414
+
2415
+ ### Default CSRF Protection
2416
+
2417
+ ```python
2418
+ # settings.py - CSRF is enabled by default
2419
+ CSRF_COOKIE_SECURE = True # Only send over HTTPS
2420
+ CSRF_COOKIE_HTTPONLY = True # Prevent JavaScript access
2421
+ CSRF_COOKIE_SAMESITE = 'Lax' # Prevent CSRF in some cases
2422
+ CSRF_TRUSTED_ORIGINS = ['https://example.com'] # Trusted domains
2423
+
2424
+ # Template usage
2425
+ <form method="post">
2426
+ {% csrf_token %}
2427
+ {{ form.as_p }}
2428
+ <button type="submit">Submit</button>
2429
+ </form>
2430
+
2431
+ # AJAX requests
2432
+ function getCookie(name) {
2433
+ let cookieValue = null;
2434
+ if (document.cookie && document.cookie !== '') {
2435
+ const cookies = document.cookie.split(';');
2436
+ for (let i = 0; i < cookies.length; i++) {
2437
+ const cookie = cookies[i].trim();
2438
+ if (cookie.substring(0, name.length + 1) === (name + '=')) {
2439
+ cookieValue = decodeURIComponent(cookie.substring(name.length + 1));
2440
+ break;
2441
+ }
2442
+ }
2443
+ }
2444
+ return cookieValue;
2445
+ }
2446
+
2447
+ fetch('/api/endpoint/', {
2448
+ method: 'POST',
2449
+ headers: {
2450
+ 'X-CSRFToken': getCookie('csrftoken'),
2451
+ 'Content-Type': 'application/json',
2452
+ },
2453
+ body: JSON.stringify(data)
2454
+ });
2455
+ ```
2456
+
2457
+ ### Exempting Views (Use Carefully)
2458
+
2459
+ ```python
2460
+ from django.views.decorators.csrf import csrf_exempt
2461
+
2462
+ @csrf_exempt # Only use when absolutely necessary!
2463
+ def webhook_view(request):
2464
+ # Webhook from external service
2465
+ pass
2466
+ ```
2467
+
2468
+ ## File Upload Security
2469
+
2470
+ ### File Validation
2471
+
2472
+ ```python
2473
+ import os
2474
+ import magic # pip install python-magic
2475
+ from django.core.exceptions import ValidationError
2476
+
2477
+ ALLOWED_MIMES = {
2478
+ 'image/jpeg', 'image/png', 'image/gif', 'application/pdf',
2479
+ }
2480
+
2481
+ MIME_TO_EXTENSIONS = {
2482
+ 'image/jpeg': {'.jpg', '.jpeg'},
2483
+ 'image/png': {'.png'},
2484
+ 'image/gif': {'.gif'},
2485
+ 'application/pdf': {'.pdf'},
2486
+ }
2487
+
2488
+ def validate_file_type(value):
2489
+ """Validate file type using magic bytes and cross-check extension."""
2490
+ mime = magic.from_buffer(value.read(2048), mime=True)
2491
+ value.seek(0)
2492
+
2493
+ if mime not in ALLOWED_MIMES:
2494
+ raise ValidationError('Unsupported file type.')
2495
+
2496
+ ext = os.path.splitext(value.name)[1].lower()
2497
+ if ext not in MIME_TO_EXTENSIONS.get(mime, set()):
2498
+ raise ValidationError('File extension does not match file content.')
2499
+
2500
+ def validate_file_size(value):
2501
+ """Validate file size (max 5MB)."""
2502
+ if value.size > 5 * 1024 * 1024:
2503
+ raise ValidationError('File too large. Max size is 5MB.')
2504
+
2505
+ # models.py
2506
+ class Document(models.Model):
2507
+ file = models.FileField(
2508
+ upload_to='documents/',
2509
+ validators=[validate_file_type, validate_file_size]
2510
+ )
2511
+
2512
+ ```
2513
+
2514
+ For environments where installing libmagic is difficult (e.g., minimal containers),
2515
+ use the pure-Python `filetype` package as an alternative:
2516
+
2517
+ ```python
2518
+ import os
2519
+ from django.core.exceptions import ValidationError
2520
+
2521
+ import filetype # pip install filetype
2522
+
2523
+ ALLOWED_MIMES = {
2524
+ 'image/jpeg', 'image/png', 'image/gif', 'application/pdf',
2525
+ }
2526
+
2527
+ MIME_TO_EXTENSIONS = {
2528
+ 'image/jpeg': {'.jpg', '.jpeg'},
2529
+ 'image/png': {'.png'},
2530
+ 'image/gif': {'.gif'},
2531
+ 'application/pdf': {'.pdf'},
2532
+ }
2533
+
2534
+ def validate_file_type(value):
2535
+ """Validate file type using magic bytes."""
2536
+ kind = filetype.guess(value.read(2048))
2537
+ value.seek(0)
2538
+
2539
+ if kind is None or kind.mime not in ALLOWED_MIMES:
2540
+ raise ValidationError('Unsupported file type.')
2541
+
2542
+ ext = os.path.splitext(value.name)[1].lower()
2543
+ if ext not in MIME_TO_EXTENSIONS.get(kind.mime, set()):
2544
+ raise ValidationError('File extension does not match file content.')
2545
+ ```
2546
+
2547
+ ### Secure File Storage
2548
+
2549
+ ```python
2550
+ # settings.py
2551
+ MEDIA_ROOT = '/var/www/media/'
2552
+ MEDIA_URL = '/media/'
2553
+
2554
+ # Use a separate domain for media in production
2555
+ MEDIA_DOMAIN = 'https://media.example.com'
2556
+
2557
+ # Don't serve user uploads directly
2558
+ # Use whitenoise or a CDN for static files
2559
+ # Use a separate server or S3 for media files
2560
+ ```
2561
+
2562
+ ## API Security
2563
+
2564
+ ### Rate Limiting
2565
+
2566
+ ```python
2567
+ # settings.py
2568
+ REST_FRAMEWORK = {
2569
+ 'DEFAULT_THROTTLE_CLASSES': [
2570
+ 'rest_framework.throttling.AnonRateThrottle',
2571
+ 'rest_framework.throttling.UserRateThrottle'
2572
+ ],
2573
+ 'DEFAULT_THROTTLE_RATES': {
2574
+ 'anon': '100/day',
2575
+ 'user': '1000/day',
2576
+ 'upload': '10/hour',
2577
+ }
2578
+ }
2579
+
2580
+ # Custom throttle
2581
+ from rest_framework.throttling import UserRateThrottle
2582
+
2583
+ class BurstRateThrottle(UserRateThrottle):
2584
+ scope = 'burst'
2585
+ rate = '60/min'
2586
+
2587
+ class SustainedRateThrottle(UserRateThrottle):
2588
+ scope = 'sustained'
2589
+ rate = '1000/day'
2590
+ ```
2591
+
2592
+ ### Authentication for APIs
2593
+
2594
+ ```python
2595
+ # settings.py
2596
+ REST_FRAMEWORK = {
2597
+ 'DEFAULT_AUTHENTICATION_CLASSES': [
2598
+ 'rest_framework.authentication.TokenAuthentication',
2599
+ 'rest_framework.authentication.SessionAuthentication',
2600
+ 'rest_framework_simplejwt.authentication.JWTAuthentication',
2601
+ ],
2602
+ 'DEFAULT_PERMISSION_CLASSES': [
2603
+ 'rest_framework.permissions.IsAuthenticated',
2604
+ ],
2605
+ }
2606
+
2607
+ # views.py
2608
+ from rest_framework.decorators import api_view, permission_classes
2609
+ from rest_framework.permissions import IsAuthenticated
2610
+
2611
+ @api_view(['GET', 'POST'])
2612
+ @permission_classes([IsAuthenticated])
2613
+ def protected_view(request):
2614
+ return Response({'message': 'You are authenticated'})
2615
+ ```
2616
+
2617
+ ## Security Headers
2618
+
2619
+ ### Content Security Policy
2620
+
2621
+ ```python
2622
+ # settings.py
2623
+ CSP_DEFAULT_SRC = "'self'"
2624
+ CSP_SCRIPT_SRC = "'self' https://cdn.example.com"
2625
+ CSP_STYLE_SRC = "'self' 'unsafe-inline'"
2626
+ CSP_IMG_SRC = "'self' data: https:"
2627
+ CSP_CONNECT_SRC = "'self' https://api.example.com"
2628
+
2629
+ # Middleware
2630
+ class CSPMiddleware:
2631
+ def __init__(self, get_response):
2632
+ self.get_response = get_response
2633
+
2634
+ def __call__(self, request):
2635
+ response = self.get_response(request)
2636
+ response['Content-Security-Policy'] = (
2637
+ f"default-src {CSP_DEFAULT_SRC}; "
2638
+ f"script-src {CSP_SCRIPT_SRC}; "
2639
+ f"style-src {CSP_STYLE_SRC}; "
2640
+ f"img-src {CSP_IMG_SRC}; "
2641
+ f"connect-src {CSP_CONNECT_SRC}"
2642
+ )
2643
+ return response
2644
+ ```
2645
+
2646
+ ## Environment Variables
2647
+
2648
+ ### Managing Secrets
2649
+
2650
+ ```python
2651
+ # Use python-decouple or django-environ
2652
+ import environ
2653
+
2654
+ env = environ.Env(
2655
+ # set casting, default value
2656
+ DEBUG=(bool, False)
2657
+ )
2658
+
2659
+ # reading .env file
2660
+ environ.Env.read_env()
2661
+
2662
+ SECRET_KEY = env('DJANGO_SECRET_KEY')
2663
+ DATABASE_URL = env('DATABASE_URL')
2664
+ ALLOWED_HOSTS = env.list('ALLOWED_HOSTS')
2665
+
2666
+ # .env file (never commit this)
2667
+ DEBUG=False
2668
+ SECRET_KEY=your-secret-key-here
2669
+ DATABASE_URL=postgresql://user:password@localhost:5432/dbname
2670
+ ALLOWED_HOSTS=example.com,www.example.com
2671
+ ```
2672
+
2673
+ ## Logging Security Events
2674
+
2675
+ ```python
2676
+ # settings.py
2677
+ LOGGING = {
2678
+ 'version': 1,
2679
+ 'disable_existing_loggers': False,
2680
+ 'handlers': {
2681
+ 'file': {
2682
+ 'level': 'WARNING',
2683
+ 'class': 'logging.FileHandler',
2684
+ 'filename': '/var/log/django/security.log',
2685
+ },
2686
+ 'console': {
2687
+ 'level': 'INFO',
2688
+ 'class': 'logging.StreamHandler',
2689
+ },
2690
+ },
2691
+ 'loggers': {
2692
+ 'django.security': {
2693
+ 'handlers': ['file', 'console'],
2694
+ 'level': 'WARNING',
2695
+ 'propagate': True,
2696
+ },
2697
+ 'django.request': {
2698
+ 'handlers': ['file'],
2699
+ 'level': 'ERROR',
2700
+ 'propagate': False,
2701
+ },
2702
+ },
2703
+ }
2704
+ ```
2705
+
2706
+ ## Quick Security Checklist
2707
+
2708
+ | Check | Description |
2709
+ |-------|-------------|
2710
+ | `DEBUG = False` | Never run with DEBUG in production |
2711
+ | HTTPS only | Force SSL, secure cookies |
2712
+ | Strong secrets | Use environment variables for SECRET_KEY |
2713
+ | Password validation | Enable all password validators |
2714
+ | CSRF protection | Enabled by default, don't disable |
2715
+ | XSS prevention | Django auto-escapes, don't use `&#124;safe` with user input |
2716
+ | SQL injection | Use ORM, never concatenate strings in queries |
2717
+ | File uploads | Validate file type and size |
2718
+ | Rate limiting | Throttle API endpoints |
2719
+ | Security headers | CSP, X-Frame-Options, HSTS |
2720
+ | Logging | Log security events |
2721
+ | Updates | Keep Django and dependencies updated |
2722
+
2723
+ Remember: Security is a process, not a product. Regularly review and update your security practices.
2724
+
2725
+ ### django-tdd
2726
+
2727
+ Django testing with pytest-django and factory_boy: TDD workflow, test settings, fixtures, model/view/serializer/DRF-API testing, mocking external services and email, and integration/full-flow tests, plus coverage targets. Use when writing or reviewing Django tests.
2728
+
2729
+ # Django Testing with TDD
2730
+
2731
+ Test-driven development for Django applications using pytest, factory_boy, and Django REST Framework.
2732
+
2733
+ ## When to Activate
2734
+
2735
+ - Writing new Django applications
2736
+ - Implementing Django REST Framework APIs
2737
+ - Testing Django models, views, and serializers
2738
+ - Setting up testing infrastructure for Django projects
2739
+
2740
+ ## TDD Workflow for Django
2741
+
2742
+ ### Red-Green-Refactor Cycle
2743
+
2744
+ ```python
2745
+ # Step 1: RED - Write failing test
2746
+ def test_user_creation():
2747
+ user = User.objects.create_user(email='test@example.com', password='testpass123')
2748
+ assert user.email == 'test@example.com'
2749
+ assert user.check_password('testpass123')
2750
+ assert not user.is_staff
2751
+
2752
+ # Step 2: GREEN - Make test pass
2753
+ # Create User model or factory
2754
+
2755
+ # Step 3: REFACTOR - Improve while keeping tests green
2756
+ ```
2757
+
2758
+ ## Setup
2759
+
2760
+ ### pytest Configuration
2761
+
2762
+ ```ini
2763
+ # pytest.ini
2764
+ [pytest]
2765
+ DJANGO_SETTINGS_MODULE = config.settings.test
2766
+ testpaths = tests
2767
+ python_files = test_*.py
2768
+ python_classes = Test*
2769
+ python_functions = test_*
2770
+ addopts =
2771
+ --reuse-db
2772
+ --nomigrations
2773
+ --cov=apps
2774
+ --cov-report=html
2775
+ --cov-report=term-missing
2776
+ --strict-markers
2777
+ markers =
2778
+ slow: marks tests as slow
2779
+ integration: marks tests as integration tests
2780
+ ```
2781
+
2782
+ ### Test Settings
2783
+
2784
+ ```python
2785
+ # config/settings/test.py
2786
+ from .base import *
2787
+
2788
+ DEBUG = True
2789
+ DATABASES = {
2790
+ 'default': {
2791
+ 'ENGINE': 'django.db.backends.sqlite3',
2792
+ 'NAME': ':memory:',
2793
+ }
2794
+ }
2795
+
2796
+ # Disable migrations for speed
2797
+ class DisableMigrations:
2798
+ def __contains__(self, item):
2799
+ return True
2800
+
2801
+ def __getitem__(self, item):
2802
+ return None
2803
+
2804
+ MIGRATION_MODULES = DisableMigrations()
2805
+
2806
+ # Faster password hashing
2807
+ PASSWORD_HASHERS = [
2808
+ 'django.contrib.auth.hashers.MD5PasswordHasher',
2809
+ ]
2810
+
2811
+ # Email backend
2812
+ EMAIL_BACKEND = 'django.core.mail.backends.console.EmailBackend'
2813
+
2814
+ # Celery always eager
2815
+ CELERY_TASK_ALWAYS_EAGER = True
2816
+ CELERY_TASK_EAGER_PROPAGATES = True
2817
+ ```
2818
+
2819
+ ### conftest.py
2820
+
2821
+ ```python
2822
+ # tests/conftest.py
2823
+ import pytest
2824
+ from django.utils import timezone
2825
+ from django.contrib.auth import get_user_model
2826
+
2827
+ User = get_user_model()
2828
+
2829
+ @pytest.fixture(autouse=True)
2830
+ def timezone_settings(settings):
2831
+ """Ensure consistent timezone."""
2832
+ settings.TIME_ZONE = 'UTC'
2833
+
2834
+ @pytest.fixture
2835
+ def user(db):
2836
+ """Create a test user."""
2837
+ return User.objects.create_user(
2838
+ email='test@example.com',
2839
+ password='testpass123',
2840
+ username='testuser'
2841
+ )
2842
+
2843
+ @pytest.fixture
2844
+ def admin_user(db):
2845
+ """Create an admin user."""
2846
+ return User.objects.create_superuser(
2847
+ email='admin@example.com',
2848
+ password='adminpass123',
2849
+ username='admin'
2850
+ )
2851
+
2852
+ @pytest.fixture
2853
+ def authenticated_client(client, user):
2854
+ """Return authenticated client."""
2855
+ client.force_login(user)
2856
+ return client
2857
+
2858
+ @pytest.fixture
2859
+ def api_client():
2860
+ """Return DRF API client."""
2861
+ from rest_framework.test import APIClient
2862
+ return APIClient()
2863
+
2864
+ @pytest.fixture
2865
+ def authenticated_api_client(api_client, user):
2866
+ """Return authenticated API client."""
2867
+ api_client.force_authenticate(user=user)
2868
+ return api_client
2869
+ ```
2870
+
2871
+ ## Factory Boy
2872
+
2873
+ ### Factory Setup
2874
+
2875
+ ```python
2876
+ # tests/factories.py
2877
+ import factory
2878
+ from factory import fuzzy
2879
+ from datetime import datetime, timedelta
2880
+ from django.contrib.auth import get_user_model
2881
+ from apps.products.models import Product, Category
2882
+
2883
+ User = get_user_model()
2884
+
2885
+ class UserFactory(factory.django.DjangoModelFactory):
2886
+ """Factory for User model."""
2887
+
2888
+ class Meta:
2889
+ model = User
2890
+
2891
+ email = factory.Sequence(lambda n: f"user{n}@example.com")
2892
+ username = factory.Sequence(lambda n: f"user{n}")
2893
+ password = factory.PostGenerationMethodCall('set_password', 'testpass123')
2894
+ first_name = factory.Faker('first_name')
2895
+ last_name = factory.Faker('last_name')
2896
+ is_active = True
2897
+
2898
+ class CategoryFactory(factory.django.DjangoModelFactory):
2899
+ """Factory for Category model."""
2900
+
2901
+ class Meta:
2902
+ model = Category
2903
+
2904
+ name = factory.Faker('word')
2905
+ slug = factory.LazyAttribute(lambda obj: obj.name.lower())
2906
+ description = factory.Faker('text')
2907
+
2908
+ class ProductFactory(factory.django.DjangoModelFactory):
2909
+ """Factory for Product model."""
2910
+
2911
+ class Meta:
2912
+ model = Product
2913
+
2914
+ name = factory.Faker('sentence', nb_words=3)
2915
+ slug = factory.LazyAttribute(lambda obj: obj.name.lower().replace(' ', '-'))
2916
+ description = factory.Faker('text')
2917
+ price = fuzzy.FuzzyDecimal(10.00, 1000.00, 2)
2918
+ stock = fuzzy.FuzzyInteger(0, 100)
2919
+ is_active = True
2920
+ category = factory.SubFactory(CategoryFactory)
2921
+ created_by = factory.SubFactory(UserFactory)
2922
+
2923
+ @factory.post_generation
2924
+ def tags(self, create, extracted, **kwargs):
2925
+ """Add tags to product."""
2926
+ if not create:
2927
+ return
2928
+ if extracted:
2929
+ for tag in extracted:
2930
+ self.tags.add(tag)
2931
+ ```
2932
+
2933
+ ### Using Factories
2934
+
2935
+ ```python
2936
+ # tests/test_models.py
2937
+ import pytest
2938
+ from tests.factories import ProductFactory, UserFactory
2939
+
2940
+ def test_product_creation():
2941
+ """Test product creation using factory."""
2942
+ product = ProductFactory(price=100.00, stock=50)
2943
+ assert product.price == 100.00
2944
+ assert product.stock == 50
2945
+ assert product.is_active is True
2946
+
2947
+ def test_product_with_tags():
2948
+ """Test product with tags."""
2949
+ tags = [TagFactory(name='electronics'), TagFactory(name='new')]
2950
+ product = ProductFactory(tags=tags)
2951
+ assert product.tags.count() == 2
2952
+
2953
+ def test_multiple_products():
2954
+ """Test creating multiple products."""
2955
+ products = ProductFactory.create_batch(10)
2956
+ assert len(products) == 10
2957
+ ```
2958
+
2959
+ ## Model Testing
2960
+
2961
+ ### Model Tests
2962
+
2963
+ ```python
2964
+ # tests/test_models.py
2965
+ import pytest
2966
+ from django.core.exceptions import ValidationError
2967
+ from tests.factories import UserFactory, ProductFactory
2968
+
2969
+ class TestUserModel:
2970
+ """Test User model."""
2971
+
2972
+ def test_create_user(self, db):
2973
+ """Test creating a regular user."""
2974
+ user = UserFactory(email='test@example.com')
2975
+ assert user.email == 'test@example.com'
2976
+ assert user.check_password('testpass123')
2977
+ assert not user.is_staff
2978
+ assert not user.is_superuser
2979
+
2980
+ def test_create_superuser(self, db):
2981
+ """Test creating a superuser."""
2982
+ user = UserFactory(
2983
+ email='admin@example.com',
2984
+ is_staff=True,
2985
+ is_superuser=True
2986
+ )
2987
+ assert user.is_staff
2988
+ assert user.is_superuser
2989
+
2990
+ def test_user_str(self, db):
2991
+ """Test user string representation."""
2992
+ user = UserFactory(email='test@example.com')
2993
+ assert str(user) == 'test@example.com'
2994
+
2995
+ class TestProductModel:
2996
+ """Test Product model."""
2997
+
2998
+ def test_product_creation(self, db):
2999
+ """Test creating a product."""
3000
+ product = ProductFactory()
3001
+ assert product.id is not None
3002
+ assert product.is_active is True
3003
+ assert product.created_at is not None
3004
+
3005
+ def test_product_slug_generation(self, db):
3006
+ """Test automatic slug generation."""
3007
+ product = ProductFactory(name='Test Product')
3008
+ assert product.slug == 'test-product'
3009
+
3010
+ def test_product_price_validation(self, db):
3011
+ """Test price cannot be negative."""
3012
+ product = ProductFactory(price=-10)
3013
+ with pytest.raises(ValidationError):
3014
+ product.full_clean()
3015
+
3016
+ def test_product_manager_active(self, db):
3017
+ """Test active manager method."""
3018
+ ProductFactory.create_batch(5, is_active=True)
3019
+ ProductFactory.create_batch(3, is_active=False)
3020
+
3021
+ active_count = Product.objects.active().count()
3022
+ assert active_count == 5
3023
+
3024
+ def test_product_stock_management(self, db):
3025
+ """Test stock management."""
3026
+ product = ProductFactory(stock=10)
3027
+ product.reduce_stock(5)
3028
+ product.refresh_from_db()
3029
+ assert product.stock == 5
3030
+
3031
+ with pytest.raises(ValueError):
3032
+ product.reduce_stock(10) # Not enough stock
3033
+ ```
3034
+
3035
+ ## View Testing
3036
+
3037
+ ### Django View Testing
3038
+
3039
+ ```python
3040
+ # tests/test_views.py
3041
+ import pytest
3042
+ from django.urls import reverse
3043
+ from tests.factories import ProductFactory, UserFactory
3044
+
3045
+ class TestProductViews:
3046
+ """Test product views."""
3047
+
3048
+ def test_product_list(self, client, db):
3049
+ """Test product list view."""
3050
+ ProductFactory.create_batch(10)
3051
+
3052
+ response = client.get(reverse('products:list'))
3053
+
3054
+ assert response.status_code == 200
3055
+ assert len(response.context['products']) == 10
3056
+
3057
+ def test_product_detail(self, client, db):
3058
+ """Test product detail view."""
3059
+ product = ProductFactory()
3060
+
3061
+ response = client.get(reverse('products:detail', kwargs={'slug': product.slug}))
3062
+
3063
+ assert response.status_code == 200
3064
+ assert response.context['product'] == product
3065
+
3066
+ def test_product_create_requires_login(self, client, db):
3067
+ """Test product creation requires authentication."""
3068
+ response = client.get(reverse('products:create'))
3069
+
3070
+ assert response.status_code == 302
3071
+ assert response.url.startswith('/accounts/login/')
3072
+
3073
+ def test_product_create_authenticated(self, authenticated_client, db):
3074
+ """Test product creation as authenticated user."""
3075
+ response = authenticated_client.get(reverse('products:create'))
3076
+
3077
+ assert response.status_code == 200
3078
+
3079
+ def test_product_create_post(self, authenticated_client, db, category):
3080
+ """Test creating a product via POST."""
3081
+ data = {
3082
+ 'name': 'Test Product',
3083
+ 'description': 'A test product',
3084
+ 'price': '99.99',
3085
+ 'stock': 10,
3086
+ 'category': category.id,
3087
+ }
3088
+
3089
+ response = authenticated_client.post(reverse('products:create'), data)
3090
+
3091
+ assert response.status_code == 302
3092
+ assert Product.objects.filter(name='Test Product').exists()
3093
+ ```
3094
+
3095
+ ## DRF API Testing
3096
+
3097
+ ### Serializer Testing
3098
+
3099
+ ```python
3100
+ # tests/test_serializers.py
3101
+ import pytest
3102
+ from rest_framework.exceptions import ValidationError
3103
+ from apps.products.serializers import ProductSerializer
3104
+ from tests.factories import ProductFactory
3105
+
3106
+ class TestProductSerializer:
3107
+ """Test ProductSerializer."""
3108
+
3109
+ def test_serialize_product(self, db):
3110
+ """Test serializing a product."""
3111
+ product = ProductFactory()
3112
+ serializer = ProductSerializer(product)
3113
+
3114
+ data = serializer.data
3115
+
3116
+ assert data['id'] == product.id
3117
+ assert data['name'] == product.name
3118
+ assert data['price'] == str(product.price)
3119
+
3120
+ def test_deserialize_product(self, db):
3121
+ """Test deserializing product data."""
3122
+ data = {
3123
+ 'name': 'Test Product',
3124
+ 'description': 'Test description',
3125
+ 'price': '99.99',
3126
+ 'stock': 10,
3127
+ 'category': 1,
3128
+ }
3129
+
3130
+ serializer = ProductSerializer(data=data)
3131
+
3132
+ assert serializer.is_valid()
3133
+ product = serializer.save()
3134
+
3135
+ assert product.name == 'Test Product'
3136
+ assert float(product.price) == 99.99
3137
+
3138
+ def test_price_validation(self, db):
3139
+ """Test price validation."""
3140
+ data = {
3141
+ 'name': 'Test Product',
3142
+ 'price': '-10.00',
3143
+ 'stock': 10,
3144
+ }
3145
+
3146
+ serializer = ProductSerializer(data=data)
3147
+
3148
+ assert not serializer.is_valid()
3149
+ assert 'price' in serializer.errors
3150
+
3151
+ def test_stock_validation(self, db):
3152
+ """Test stock cannot be negative."""
3153
+ data = {
3154
+ 'name': 'Test Product',
3155
+ 'price': '99.99',
3156
+ 'stock': -5,
3157
+ }
3158
+
3159
+ serializer = ProductSerializer(data=data)
3160
+
3161
+ assert not serializer.is_valid()
3162
+ assert 'stock' in serializer.errors
3163
+ ```
3164
+
3165
+ ### API ViewSet Testing
3166
+
3167
+ ```python
3168
+ # tests/test_api.py
3169
+ import pytest
3170
+ from rest_framework.test import APIClient
3171
+ from rest_framework import status
3172
+ from django.urls import reverse
3173
+ from tests.factories import ProductFactory, UserFactory
3174
+
3175
+ class TestProductAPI:
3176
+ """Test Product API endpoints."""
3177
+
3178
+ @pytest.fixture
3179
+ def api_client(self):
3180
+ """Return API client."""
3181
+ return APIClient()
3182
+
3183
+ def test_list_products(self, api_client, db):
3184
+ """Test listing products."""
3185
+ ProductFactory.create_batch(10)
3186
+
3187
+ url = reverse('api:product-list')
3188
+ response = api_client.get(url)
3189
+
3190
+ assert response.status_code == status.HTTP_200_OK
3191
+ assert response.data['count'] == 10
3192
+
3193
+ def test_retrieve_product(self, api_client, db):
3194
+ """Test retrieving a product."""
3195
+ product = ProductFactory()
3196
+
3197
+ url = reverse('api:product-detail', kwargs={'pk': product.id})
3198
+ response = api_client.get(url)
3199
+
3200
+ assert response.status_code == status.HTTP_200_OK
3201
+ assert response.data['id'] == product.id
3202
+
3203
+ def test_create_product_unauthorized(self, api_client, db):
3204
+ """Test creating product without authentication."""
3205
+ url = reverse('api:product-list')
3206
+ data = {'name': 'Test Product', 'price': '99.99'}
3207
+
3208
+ response = api_client.post(url, data)
3209
+
3210
+ assert response.status_code == status.HTTP_401_UNAUTHORIZED
3211
+
3212
+ def test_create_product_authorized(self, authenticated_api_client, db):
3213
+ """Test creating product as authenticated user."""
3214
+ url = reverse('api:product-list')
3215
+ data = {
3216
+ 'name': 'Test Product',
3217
+ 'description': 'Test',
3218
+ 'price': '99.99',
3219
+ 'stock': 10,
3220
+ }
3221
+
3222
+ response = authenticated_api_client.post(url, data)
3223
+
3224
+ assert response.status_code == status.HTTP_201_CREATED
3225
+ assert response.data['name'] == 'Test Product'
3226
+
3227
+ def test_update_product(self, authenticated_api_client, db):
3228
+ """Test updating a product."""
3229
+ product = ProductFactory(created_by=authenticated_api_client.user)
3230
+
3231
+ url = reverse('api:product-detail', kwargs={'pk': product.id})
3232
+ data = {'name': 'Updated Product'}
3233
+
3234
+ response = authenticated_api_client.patch(url, data)
3235
+
3236
+ assert response.status_code == status.HTTP_200_OK
3237
+ assert response.data['name'] == 'Updated Product'
3238
+
3239
+ def test_delete_product(self, authenticated_api_client, db):
3240
+ """Test deleting a product."""
3241
+ product = ProductFactory(created_by=authenticated_api_client.user)
3242
+
3243
+ url = reverse('api:product-detail', kwargs={'pk': product.id})
3244
+ response = authenticated_api_client.delete(url)
3245
+
3246
+ assert response.status_code == status.HTTP_204_NO_CONTENT
3247
+
3248
+ def test_filter_products_by_price(self, api_client, db):
3249
+ """Test filtering products by price."""
3250
+ ProductFactory(price=50)
3251
+ ProductFactory(price=150)
3252
+
3253
+ url = reverse('api:product-list')
3254
+ response = api_client.get(url, {'price_min': 100})
3255
+
3256
+ assert response.status_code == status.HTTP_200_OK
3257
+ assert response.data['count'] == 1
3258
+
3259
+ def test_search_products(self, api_client, db):
3260
+ """Test searching products."""
3261
+ ProductFactory(name='Apple iPhone')
3262
+ ProductFactory(name='Samsung Galaxy')
3263
+
3264
+ url = reverse('api:product-list')
3265
+ response = api_client.get(url, {'search': 'Apple'})
3266
+
3267
+ assert response.status_code == status.HTTP_200_OK
3268
+ assert response.data['count'] == 1
3269
+ ```
3270
+
3271
+ ## Mocking and Patching
3272
+
3273
+ ### Mocking External Services
3274
+
3275
+ ```python
3276
+ # tests/test_views.py
3277
+ from unittest.mock import patch, Mock
3278
+ import pytest
3279
+
3280
+ class TestPaymentView:
3281
+ """Test payment view with mocked payment gateway."""
3282
+
3283
+ @patch('apps.payments.services.stripe')
3284
+ def test_successful_payment(self, mock_stripe, client, user, product):
3285
+ """Test successful payment with mocked Stripe."""
3286
+ # Configure mock
3287
+ mock_stripe.Charge.create.return_value = {
3288
+ 'id': 'ch_123',
3289
+ 'status': 'succeeded',
3290
+ 'amount': 9999,
3291
+ }
3292
+
3293
+ client.force_login(user)
3294
+ response = client.post(reverse('payments:process'), {
3295
+ 'product_id': product.id,
3296
+ 'token': 'tok_visa',
3297
+ })
3298
+
3299
+ assert response.status_code == 302
3300
+ mock_stripe.Charge.create.assert_called_once()
3301
+
3302
+ @patch('apps.payments.services.stripe')
3303
+ def test_failed_payment(self, mock_stripe, client, user, product):
3304
+ """Test failed payment."""
3305
+ mock_stripe.Charge.create.side_effect = Exception('Card declined')
3306
+
3307
+ client.force_login(user)
3308
+ response = client.post(reverse('payments:process'), {
3309
+ 'product_id': product.id,
3310
+ 'token': 'tok_visa',
3311
+ })
3312
+
3313
+ assert response.status_code == 302
3314
+ assert 'error' in response.url
3315
+ ```
3316
+
3317
+ ### Mocking Email Sending
3318
+
3319
+ ```python
3320
+ # tests/test_email.py
3321
+ from django.core import mail
3322
+ from django.test import override_settings
3323
+
3324
+ @override_settings(EMAIL_BACKEND='django.core.mail.backends.locmem.EmailBackend')
3325
+ def test_order_confirmation_email(db, order):
3326
+ """Test order confirmation email."""
3327
+ order.send_confirmation_email()
3328
+
3329
+ assert len(mail.outbox) == 1
3330
+ assert order.user.email in mail.outbox[0].to
3331
+ assert 'Order Confirmation' in mail.outbox[0].subject
3332
+ ```
3333
+
3334
+ ## Integration Testing
3335
+
3336
+ ### Full Flow Testing
3337
+
3338
+ ```python
3339
+ # tests/test_integration.py
3340
+ import pytest
3341
+ from django.urls import reverse
3342
+ from tests.factories import UserFactory, ProductFactory
3343
+
3344
+ class TestCheckoutFlow:
3345
+ """Test complete checkout flow."""
3346
+
3347
+ def test_guest_to_purchase_flow(self, client, db):
3348
+ """Test complete flow from guest to purchase."""
3349
+ # Step 1: Register
3350
+ response = client.post(reverse('users:register'), {
3351
+ 'email': 'test@example.com',
3352
+ 'password': 'testpass123',
3353
+ 'password_confirm': 'testpass123',
3354
+ })
3355
+ assert response.status_code == 302
3356
+
3357
+ # Step 2: Login
3358
+ response = client.post(reverse('users:login'), {
3359
+ 'email': 'test@example.com',
3360
+ 'password': 'testpass123',
3361
+ })
3362
+ assert response.status_code == 302
3363
+
3364
+ # Step 3: Browse products
3365
+ product = ProductFactory(price=100)
3366
+ response = client.get(reverse('products:detail', kwargs={'slug': product.slug}))
3367
+ assert response.status_code == 200
3368
+
3369
+ # Step 4: Add to cart
3370
+ response = client.post(reverse('cart:add'), {
3371
+ 'product_id': product.id,
3372
+ 'quantity': 1,
3373
+ })
3374
+ assert response.status_code == 302
3375
+
3376
+ # Step 5: Checkout
3377
+ response = client.get(reverse('checkout:review'))
3378
+ assert response.status_code == 200
3379
+ assert product.name in response.content.decode()
3380
+
3381
+ # Step 6: Complete purchase
3382
+ with patch('apps.checkout.services.process_payment') as mock_payment:
3383
+ mock_payment.return_value = True
3384
+ response = client.post(reverse('checkout:complete'))
3385
+
3386
+ assert response.status_code == 302
3387
+ assert Order.objects.filter(user__email='test@example.com').exists()
3388
+ ```
3389
+
3390
+ ## Testing Best Practices
3391
+
3392
+ ### DO
3393
+
3394
+ - **Use factories**: Instead of manual object creation
3395
+ - **One assertion per test**: Keep tests focused
3396
+ - **Descriptive test names**: `test_user_cannot_delete_others_post`
3397
+ - **Test edge cases**: Empty inputs, None values, boundary conditions
3398
+ - **Mock external services**: Don't depend on external APIs
3399
+ - **Use fixtures**: Eliminate duplication
3400
+ - **Test permissions**: Ensure authorization works
3401
+ - **Keep tests fast**: Use `--reuse-db` and `--nomigrations`
3402
+
3403
+ ### DON'T
3404
+
3405
+ - **Don't test Django internals**: Trust Django to work
3406
+ - **Don't test third-party code**: Trust libraries to work
3407
+ - **Don't ignore failing tests**: All tests must pass
3408
+ - **Don't make tests dependent**: Tests should run in any order
3409
+ - **Don't over-mock**: Mock only external dependencies
3410
+ - **Don't test private methods**: Test public interface
3411
+ - **Don't use production database**: Always use test database
3412
+
3413
+ ## Coverage
3414
+
3415
+ ### Coverage Configuration
3416
+
3417
+ ```bash
3418
+ # Run tests with coverage
3419
+ pytest --cov=apps --cov-report=html --cov-report=term-missing
3420
+
3421
+ # Generate HTML report
3422
+ open htmlcov/index.html
3423
+ ```
3424
+
3425
+ ### Coverage Goals
3426
+
3427
+ | Component | Target Coverage |
3428
+ |-----------|-----------------|
3429
+ | Models | 90%+ |
3430
+ | Serializers | 85%+ |
3431
+ | Views | 80%+ |
3432
+ | Services | 90%+ |
3433
+ | Utilities | 80%+ |
3434
+ | Overall | 80%+ |
3435
+
3436
+ ## Quick Reference
3437
+
3438
+ | Pattern | Usage |
3439
+ |---------|-------|
3440
+ | `@pytest.mark.django_db` | Enable database access |
3441
+ | `client` | Django test client |
3442
+ | `api_client` | DRF API client |
3443
+ | `factory.create_batch(n)` | Create multiple objects |
3444
+ | `patch('module.function')` | Mock external dependencies |
3445
+ | `override_settings` | Temporarily change settings |
3446
+ | `force_authenticate()` | Bypass authentication in tests |
3447
+ | `assertRedirects` | Check for redirects |
3448
+ | `assertTemplateUsed` | Verify template usage |
3449
+ | `mail.outbox` | Check sent emails |
3450
+
3451
+ Remember: Tests are documentation. Good tests explain how your code should work. Keep them simple, readable, and maintainable.