django-tdd · diff

v2.0.0 to v2.0.0

247 added, 86 removed. Audit A to A.

---
name: django-tdd
description: Use when testing Django applications with pytest - TDD workflow, pytest-django setup, factory_boy and model-bakery fixtures, DRF API testing, mocking and patching, integration tests, or coverage
metadata:
author: mte90
version: 2.0.0
tags:
- python
- django
- testing
- pytest
- tdd
---
# Django Testing with TDD
Test-driven development for Django applications using pytest, factory_boy, and Django REST Framework.
## When to Activate
- Writing new Django applications
- Implementing Django REST Framework APIs
- Testing Django models, views, and serializers
- Setting up testing infrastructure for Django projects
## TDD Workflow for Django
### Red-Green-Refactor Cycle
```python
# Step 1: RED - Write failing test
def test_user_creation():
user = User.objects.create_user(email='test@example.com', password='testpass123')
assert user.email == 'test@example.com'
assert user.check_password('testpass123')
assert not user.is_staff
# Step 2: GREEN - Make test pass
# Create User model or factory
# Step 3: REFACTOR - Improve while keeping tests green
```
## Setup
### pytest Configuration
```ini
# pytest.ini
[pytest]
DJANGO_SETTINGS_MODULE = config.settings.test
testpaths = tests
python_files = test_*.py
python_classes = Test*
python_functions = test_*
addopts =
--reuse-db
--nomigrations
--cov=apps
--cov-report=html
--cov-report=term-missing
--strict-markers
markers =
slow: marks tests as slow
integration: marks tests as integration tests
```
### Test Settings
```python
# config/settings/test.py
from .base import *
DEBUG = True
DATABASES = {
'default': {
'ENGINE': 'django.db.backends.sqlite3',
'NAME': ':memory:',
}
}
# Disable migrations for speed
class DisableMigrations:
def __contains__(self, item):
return True
def __getitem__(self, item):
return None
MIGRATION_MODULES = DisableMigrations()
# Faster password hashing
PASSWORD_HASHERS = [
'django.contrib.auth.hashers.MD5PasswordHasher',
]
# Email backend
EMAIL_BACKEND = 'django.core.mail.backends.console.EmailBackend'
# Celery always eager
CELERY_TASK_ALWAYS_EAGER = True
CELERY_TASK_EAGER_PROPAGATES = True
```
### conftest.py
```python
# tests/conftest.py
import pytest
from django.utils import timezone
from django.contrib.auth import get_user_model
User = get_user_model()
@pytest.fixture(autouse=True)
def timezone_settings(settings):
"""Ensure consistent timezone."""
settings.TIME_ZONE = 'UTC'
@pytest.fixture
def user(db):
"""Create a test user."""
return User.objects.create_user(
email='test@example.com',
password='testpass123',
username='testuser'
)
@pytest.fixture
def admin_user(db):
"""Create an admin user."""
return User.objects.create_superuser(
email='admin@example.com',
password='adminpass123',
username='admin'
)
@pytest.fixture
def authenticated_client(client, user):
"""Return authenticated client."""
client.force_login(user)
return client
@pytest.fixture
def api_client():
"""Return DRF API client."""
from rest_framework.test import APIClient
return APIClient()
@pytest.fixture
def authenticated_api_client(api_client, user):
"""Return authenticated API client."""
api_client.force_authenticate(user=user)
return api_client
```
- ## Factory Boy
+ ## Factory Boy Patterns
- ### Factory Setup
+ ### Core Patterns with Failure Modes
+ **Lazy attributes** — computed values that depend on other fields:
+
```python
+ slug = factory.LazyAttribute(lambda obj: obj.name.lower().replace(' ', '-'))
+ ```
+ *Failure:* `LazyAttribute` runs at object creation — if the model's `save()` overrides it, the factory value is silently lost.
+
+ **Sequence for unique values**:
+
+ ```python
+ email = factory.Sequence(lambda n: f"user{n}@example.com")
+ ```
+ *Failure:* Sequence state persists across test runs; parallel tests collide on unique constraints.
+
+ **SubFactory for relationships**:
+
+ ```python
+ category = factory.SubFactory(CategoryFactory)
+ ```
+ *Failure:* Nested SubFactory creates database hits; tests slow down exponentially with depth.
+
+ **django_get_or_create for M2M through models**:
+
+ ```python
+ tags = factory.RelatedFactoryList(TagFactory, 'product_tags', _quantity=3)
+ ```
+ *Failure:* `get_or_create` hides uniqueness violations — the test passes even when the real code would fail on duplicate keys.
+
+ **post_generation for M2M relationships**:
+
+ ```python
+ @factory.post_generation
+ def tags(self, create, extracted, **kwargs):
+ if not create:
+ return
+ if extracted:
+ for tag in extracted:
+ self.tags.add(tag)
+ ```
+ *Failure:* If `post_generation` is missing or returns early, M2M rows are absent and the test passes for the wrong reason.
+
+ **Meta.freeze for immutable models**:
+
+ ```python
+ class Meta:
+ model = Invoice
+ exclude = ('created_at',)
+
+ @classmethod
+ def _generate(cls, create_class, _build, *args, **kwargs):
+ obj = super()._generate(create_class, _build, *args, **kwargs)
+ obj.created_at = timezone.now() # Set after save
+ return obj
+ ```
+ *Failure:* A non-frozen factory lets one test's mutation leak into another — `created_at` changes mid-test and assertions fail intermittently.
+
+ ### Minimal Factory Example
+
+ ```python
# tests/factories.py
import factory
from factory import fuzzy
- from datetime import datetime, timedelta
from django.contrib.auth import get_user_model
- from apps.products.models import Product, Category
User = get_user_model()
class UserFactory(factory.django.DjangoModelFactory):
- """Factory for User model."""
-
class Meta:
model = User
email = factory.Sequence(lambda n: f"user{n}@example.com")
username = factory.Sequence(lambda n: f"user{n}")
password = factory.PostGenerationMethodCall('set_password', 'testpass123')
- first_name = factory.Faker('first_name')
- last_name = factory.Faker('last_name')
- is_active = True
- class CategoryFactory(factory.django.DjangoModelFactory):
- """Factory for Category model."""
-
- class Meta:
- model = Category
-
- name = factory.Faker('word')
- slug = factory.LazyAttribute(lambda obj: obj.name.lower())
- description = factory.Faker('text')
-
class ProductFactory(factory.django.DjangoModelFactory):
- """Factory for Product model."""
-
class Meta:
model = Product
name = factory.Faker('sentence', nb_words=3)
- slug = factory.LazyAttribute(lambda obj: obj.name.lower().replace(' ', '-'))
- description = factory.Faker('text')
price = fuzzy.FuzzyDecimal(10.00, 1000.00, 2)
- stock = fuzzy.FuzzyInteger(0, 100)
- is_active = True
category = factory.SubFactory(CategoryFactory)
- created_by = factory.SubFactory(UserFactory)
@factory.post_generation
def tags(self, create, extracted, **kwargs):
- """Add tags to product."""
- if not create:
+ if not create or not extracted:
return
- if extracted:
- for tag in extracted:
- self.tags.add(tag)
+ for tag in extracted:
+ self.tags.add(tag)
```
### Using Factories
```python
# tests/test_models.py
import pytest
from tests.factories import ProductFactory, UserFactory
def test_product_creation():
- """Test product creation using factory."""
product = ProductFactory(price=100.00, stock=50)
assert product.price == 100.00
assert product.stock == 50
- assert product.is_active is True
- def test_product_with_tags():
- """Test product with tags."""
- tags = [TagFactory(name='electronics'), TagFactory(name='new')]
- product = ProductFactory(tags=tags)
- assert product.tags.count() == 2
-
def test_multiple_products():
- """Test creating multiple products."""
products = ProductFactory.create_batch(10)
assert len(products) == 10
```
- ## Testing Best Practices
+ ## Transaction and Isolation Testing
- ### DO
+ ### pytest.mark.django_db vs transaction=True
- - **Use factories**: Instead of manual object creation
- - **One assertion per test**: Keep tests focused
- - **Descriptive test names**: `test_user_cannot_delete_others_post`
- - **Test edge cases**: Empty inputs, None values, boundary conditions
- - **Mock external services**: Don't depend on external APIs
- - **Use fixtures**: Eliminate duplication
- - **Test permissions**: Ensure authorization works
- - **Keep tests fast**: Use `--reuse-db` and `--nomigrations`
+ `@pytest.mark.django_db` wraps each test in a transaction and rolls back at the end. Fast, but:
- ### DON'T
+ - **Failure mode:** `TransactionManagementError` when code explicitly commits or uses `transaction.atomic()` incorrectly
+ - **Failure mode:** Data persists across tests if the transaction doesn't roll back (e.g., unhandled exception before rollback)
- - **Don't test Django internals**: Trust Django to work
- - **Don't test third-party code**: Trust libraries to work
- - **Don't ignore failing tests**: All tests must pass
- - **Don't make tests dependent**: Tests should run in any order
- - **Don't over-mock**: Mock only external dependencies
- - **Don't test private methods**: Test public interface
- - **Don't use production database**: Always use test database
+ `transaction=True` disables transaction wrapping and uses real transactions:
- ## Quick Reference
+ ```python
+ @pytest.mark.django_db(transaction=True)
+ def test_real_transaction():
+ # Actual commit/rollback behavior
+ pass
+ ```
- | Pattern | Usage |
- |---------|-------|
- | `@pytest.mark.django_db` | Enable database access |
- | `client` | Django test client |
- | `api_client` | DRF API client |
- | `factory.create_batch(n)` | Create multiple objects |
- | `patch('module.function')` | Mock external dependencies |
- | `override_settings` | Temporarily change settings |
- | `force_authenticate()` | Bypass authentication in tests |
- | `assertRedirects` | Check for redirects |
- | `assertTemplateUsed` | Verify template usage |
- | `mail.outbox` | Check sent emails |
+ - **Failure mode:** Missing data across tests — each test truncates tables, so fixtures from other tests are gone
+ - **Cost:** ~10x slower due to table truncation between tests
- Remember: Tests are documentation. Good tests explain how your code should work. Keep them simple, readable, and maintainable.
+ **When to use `transaction=True`:**
+ - Testing code that relies on `transaction.on_commit()` callbacks
+ - Testing concurrent database access (multiple threads/processes)
+ - Testing migration data that must survive a real commit
+ **When to stick with default:**
+ - Most unit tests — speed matters
+ - Tests that only read/write within a single test function
+
## Testing Pitfalls
- ### Mocking `connections` imported at module scope
+ ### Mocking Pitfalls
- If the code under test does `from django.db import connections` at the top of the file, patch `module_under_test.connections`, not `django.db.connections`. The module bound its own reference at import time and won't see the global patch.
+ **Patching `request.user` on the class instead of instance**
- ### `patch.object` cannot mock dunders on instances
+ ```python
+ # WRONG — patches the attribute on the class, leaks across tests
+ @patch('django.contrib.auth.models.User')
+ def test_view(mock_user):
+ ...
- `patch.object(connections, "__getitem__", ...)` silently fails. Mock at the class level (`patch.object(type(connections), "__getitem__", ...)`) or substitute a wrapper object that defines `__getitem__`.
+ # RIGHT — patch on the instance or use client.force_login
+ def test_view(client, user):
+ client.force_login(user)
+ ...
+ ```
+ *Failure:* User state from one test appears in another; authentication checks pass for the wrong user.
- ### Always use timezone-aware datetimes
+ **Patching a manager method vs the queryset**
- Naive datetimes trigger `RuntimeWarning` and, when `filterwarnings = ["error"]` is set, fail the suite. Use `django.utils.timezone.now()` or `timezone.make_aware(datetime(...))`.
+ ```python
+ # Model.objects.filter returns a QuerySet that must be evaluated
+ # WRONG — patching the method but not evaluating
+ @patch('apps.models.Product.objects.filter')
+ def test_list_products(mock_filter):
+ mock_filter.return_value = [ProductFactory()] # Returns list, not evaluated QuerySet
+ ...
+ # RIGHT — patch at the point of evaluation or use MagicMock
+ @patch('apps.models.Product.objects.filter')
+ def test_list_products(mock_filter):
+ mock_filter.return_value = MagicMock(__iter__=Mock(return_value=iter([ProductFactory()])))
+ ...
+ ```
+ *Failure:* QuerySet evaluation returns empty; test passes with no data when real code would return results.
+
+ **Patching a decorated view**
+
```python
- from django.utils import timezone
+ # WRONG — decorator wraps the function; patch target is wrapper's module attribute
+ @patch('apps.views.my_view')
+ def test_view(mock_view):
+ ...
- # WRONG
- started_at = datetime(2025, 1, 1, 12, 0, 0)
+ # RIGHT — patch the module-level attribute after decoration
+ @patch('apps.views.my_view') # Target the decorated wrapper, not the undecorated name
+ def test_view(mock_view):
+ ...
+ ```
+ *Failure:* Patch doesn't apply; the real view runs and hits the database.
- # RIGHT
- started_at = timezone.now()
- started_at = timezone.make_aware(datetime(2025, 1, 1, 12, 0, 0))
+ **Using `override_settings` instead of `mock.patch` for settings evaluated at import time**
+
+ ```python
+ # WRONG — settings evaluated at import time, override_settings does nothing
+ from myapp import CONSTANT # CONSTANT computed from settings at import
+
+ @override_settings(MY_SETTING='new_value')
+ def test_something():
+ assert CONSTANT == 'expected' # Still old value
+
+ # RIGHT — mock the module-level constant or restructure to lazy evaluation
+ @patch('myapp.CONSTANT', 'new_value')
+ def test_something(mock_constant):
+ ...
```
+ *Failure:* Settings patch has no effect; test passes with wrong configuration.
- ### `auto_now_add` in tests
+ **Mocking a third-party HTTP client with un-asserted mock**
- A `DateTimeField(auto_now_add=True)` ignores values passed to the constructor. Set the value after `save()`, not before:
+ ```python
+ # WRONG — mock returns None or empty dict by default
+ @patch('requests.get')
+ def test_external_api(mock_get):
+ response = make_api_call() # Uses requests.get internally
+ assert response.status == 200 # Fails if mock_get.return_value is None
+ # RIGHT — configure mock to return realistic response shape
+ mock_get.return_value = Mock(status_code=200, json=lambda: {'data': 'value'})
+ ```
+ *Failure:* Mock hides the real response shape; test passes but integration fails with actual API.
+
+ ### Common Anti-Patterns
+
+ **Assert on response status only**
+
```python
+ # WRONG — 200 with wrong content passes
+ response = client.get('/api/items/')
+ assert response.status_code == 200
+
+ # RIGHT — assert on content structure
+ assert response.status_code == 200
+ assert response.data['count'] == 10
+ assert 'results' in response.data
+ ```
+ *Failure:* Endpoint returns 200 with empty or malformed data; test passes, production breaks.
+
+ **`assertTrue(form.is_valid())` without asserting `form.errors`**
+
+ ```python
+ # WRONG — form fails for wrong reason, test still passes
+ assert form.is_valid()
+
+ # RIGHT — assert specific errors
+ assert not form.is_valid()
+ assert 'email' in form.errors
+ assert 'Invalid email format' in form.errors['email'][0]
+ ```
+ *Failure:* Form validates when it shouldn't, or fails for unexpected reasons; test gives false confidence.
+
+ **`TestCase` wrapping a test that needs real transactions**
+
+ ```python
+ # WRONG — TestCase wraps everything in transaction; real commit behavior hidden
+ class TestPayment(TestCase):
+ def test_payment_commit(self):
+ # Code uses transaction.on_commit() — never fires in TestCase
+ ...
+
+ # RIGHT — use TransactionTestCase or pytest-django with transaction=True
+ @pytest.mark.django_db(transaction=True)
+ def test_payment_commit():
+ ...
+ ```
+ *Failure:* Test passes but `on_commit()` callbacks never execute in production; side effects (emails, webhooks) are lost.
+
+ **Mocking what you should build**
+
+ ```python
+ # WRONG — mock your own models
+ @patch('apps.models.Order')
+ def test_checkout(mock_order):
+ ...
+
+ # RIGHT — use factories for your own models
+ def test_checkout():
+ order = OrderFactory()
+ ...
+ ```
+ *Failure:* Mock doesn't reflect model behavior (validations, managers, relationships); test diverges from reality.
+
+ ### Django-Specific Gotchas
+
+ **Mocking `connections` imported at module scope**
+
+ If the code under test does `from django.db import connections` at the top, patch `module_under_test.connections`, not `django.db.connections`. The module bound its own reference at import time.
+
+ **`patch.object` cannot mock dunders on instances**
+
+ `patch.object(connections, "__getitem__", ...)` silently fails. Mock at class level (`patch.object(type(connections), "__getitem__", ...)`) or substitute a wrapper object.
+
+ **Always use timezone-aware datetimes**
+
+ Naive datetimes trigger `RuntimeWarning` and fail with `filterwarnings = ["error"]`. Use `django.utils.timezone.now()` or `timezone.make_aware()`.
+
+ **`auto_now_add` in tests**
+
+ `DateTimeField(auto_now_add=True)` ignores values passed to constructor. Set value after `save()`:
+
+ ```python
invoice = Invoice(customer=c)
invoice.save()
invoice.issued_at = some_time
invoice.save()
```
+ ## Quick Reference
+
+ | Pattern | Usage |
+ |---------|-------|
+ | `@pytest.mark.django_db` | Enable database access |
+ | `client` | Django test client |
+ | `api_client` | DRF API client |
+ | `factory.create_batch(n)` | Create multiple objects |
+ | `patch('module.function')` | Mock external dependencies |
+ | `override_settings` | Temporarily change settings |
+ | `force_authenticate()` | Bypass authentication in tests |
+ | `assertRedirects` | Check for redirects |
+ | `assertTemplateUsed` | Verify template usage |
+ | `mail.outbox` | Check sent emails |
+
## Deep Dives
Load these reference files for detailed coverage of specific testing areas:
- **Model & View Testing** — `references/model-view-testing.md` — Model tests, view tests, authentication checks
- **DRF API Testing** — `references/drf-api-testing.md` — Serializer tests, ViewSet tests, API endpoints
- **Mocking & Integration** — `references/mocking-integration.md` — External service mocking, email testing, full flow tests
- **Ecosystem & Coverage** — `references/ecosystem-coverage.md` — model-bakery, django-test-migrations, django-test-plus, coverage goals
+