aboutsummaryrefslogtreecommitdiff
path: root/languages/python/claude/rules/python-testing.md
diff options
context:
space:
mode:
Diffstat (limited to 'languages/python/claude/rules/python-testing.md')
-rw-r--r--languages/python/claude/rules/python-testing.md101
1 files changed, 101 insertions, 0 deletions
diff --git a/languages/python/claude/rules/python-testing.md b/languages/python/claude/rules/python-testing.md
new file mode 100644
index 0000000..6f04b7f
--- /dev/null
+++ b/languages/python/claude/rules/python-testing.md
@@ -0,0 +1,101 @@
+# Python Testing Rules
+
+Applies to: `**/*.py`
+
+Implements the core principles from `testing.md`. All rules there apply here —
+this file covers Python-specific patterns.
+
+## Framework: pytest (NEVER unittest)
+
+Use `pytest` for all Python tests. Do not use `unittest.TestCase` unless
+integrating with legacy code that requires it.
+
+## Test Structure
+
+Group tests in classes that mirror the source module:
+
+```python
+class TestCartService:
+ """Tests for CartService."""
+
+ @pytest.fixture
+ def cart(self):
+ return Cart(user_id=42)
+
+ def test_add_item_normal(self, cart):
+ """Normal: adding an in-stock item increases quantity."""
+ cart.add("SKU-1", quantity=2)
+ assert cart.item_count("SKU-1") == 2
+
+ def test_add_item_boundary_zero_quantity(self, cart):
+ """Boundary: quantity 0 is a no-op, not an error."""
+ cart.add("SKU-1", quantity=0)
+ assert cart.item_count("SKU-1") == 0
+
+ def test_add_item_error_negative(self, cart):
+ """Error: negative quantity raises ValueError."""
+ with pytest.raises(ValueError, match="quantity must be non-negative"):
+ cart.add("SKU-1", quantity=-1)
+```
+
+## Fixtures Over Factories
+
+- Use `pytest` fixtures for test data setup
+- Use `@pytest.fixture(autouse=True)` sparingly — prefer explicit injection
+- Avoid `factory_boy` unless object graphs are genuinely complex
+- Django: prefer pytest fixtures over `setUpTestData` unless you have a
+ performance reason
+
+## Parametrize for Category Coverage
+
+Use `@pytest.mark.parametrize` to cover normal, boundary, and error cases
+concisely instead of hand-writing near-duplicate tests:
+
+```python
+@pytest.mark.parametrize("quantity,valid", [
+ (1, True), # Normal
+ (100, True), # Normal: bulk
+ (0, True), # Boundary: zero is a no-op
+ (-1, False), # Error: negative
+])
+def test_add_item_quantity_validation(cart, quantity, valid):
+ if valid:
+ cart.add("SKU-1", quantity=quantity)
+ else:
+ with pytest.raises(ValueError):
+ cart.add("SKU-1", quantity=quantity)
+```
+
+## Mocking Guidelines
+
+### Mock these (external boundaries):
+- External APIs (`requests`, `httpx`, `boto3` clients)
+- Time (`freezegun` or `time-machine`)
+- File uploads (Django: `SimpleUploadedFile`)
+- Celery tasks (`@override_settings(CELERY_ALWAYS_EAGER=True)`)
+- Email sending (Django: `django.core.mail.outbox`)
+
+### Never mock these (internal domain):
+- ORM queries (SQLAlchemy, Django ORM)
+- Model methods and properties
+- Form and serializer validation
+- Middleware
+- Your own service functions
+
+## Async Testing
+
+Use `anyio` for async tests (not raw `asyncio`):
+
+```python
+@pytest.mark.anyio
+async def test_process_order_async():
+ result = await process_order_async(sample_order)
+ assert result.status == "processed"
+```
+
+## Database Testing (Django)
+
+- Mark database tests with `@pytest.mark.django_db`
+- Use transactions for isolation (pytest-django default)
+- Prefer in-memory SQLite for speed in unit tests
+- Use `select_related` / `prefetch_related` assertions to catch N+1 regressions