django-tdd · diff
v1.0.0 to v2.0.0
11 added, 528 removed. Audit A to A.
---
name: django-tdd
- description: Django testing strategies with pytest-django, TDD methodology, factory_boy, mocking, coverage, and testing Django REST Framework APIs.
+ 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: 1.0.0
+ 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 Setup
```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:
return
if extracted:
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
```
-
- ## Ecosystem Libraries
-
- ### model-bakery
-
- Lightweight object factory for Django, simpler alternative to factory_boy.
-
- https://github.com/model-bakers/model_bakery
-
- ```python
- from model_bakery import baker
-
- # Create single instance
- user = baker.make("auth.User", username="test")
-
- # Create multiple instances
- users = baker.make("auth.User", _quantity=5)
-
- # With relationships
- order = baker.make("store.Order", user=baker.make("auth.User"))
- ```
-
- Smart recipe pattern for reusable test fixtures:
-
- ```python
- from model_bakery import baker
-
- # Pre-define recipe
- user_recipe = baker.recipe("auth.User", is_staff=True)
-
- # Use recipe
- staff_user = baker.make(user_recipe)
- ```
-
- ### django-test-migrations
-
- Test schema and data migrations including migration order verification.
-
- https://github.com/wemake-services/django-test-migrations
-
- ```python
- from django_test_migrations.contrib.unittest_checks import TestCaseWithMigrationsChecks
-
- class TestMigrations(TestCaseWithMigrationsChecks):
- def test_migration_order(self):
- # Verify migrations run in correct order
- self.assertNoConflictingMigrations()
-
- def test_migration_data(self):
- # Test data migrations
- self.assertForwardPathThenBackwardPath()
- ```
-
- ### django-test-plus
-
- Useful TestCase additions (assertLoginRequired, simplified request handling).
-
- https://github.com/revsys/django-test-plus
-
- ```python
- from test_plus.test import TestCase
-
- class MyTest(TestCase):
- def test_login_required(self):
- self.assertLoginRequired("/protected/")
-
- def test_view(self):
- # Built-in request factory
- self.get("/view/")
- self.post("/view/", {"key": "value"})
- ```
- ## Model Testing
-
- ### Model Tests
-
- ```python
- # tests/test_models.py
- import pytest
- from django.core.exceptions import ValidationError
- from tests.factories import UserFactory, ProductFactory
-
- class TestUserModel:
- """Test User model."""
-
- def test_create_user(self, db):
- """Test creating a regular user."""
- user = UserFactory(email='test@example.com')
- assert user.email == 'test@example.com'
- assert user.check_password('testpass123')
- assert not user.is_staff
- assert not user.is_superuser
-
- def test_create_superuser(self, db):
- """Test creating a superuser."""
- user = UserFactory(
- email='admin@example.com',
- is_staff=True,
- is_superuser=True
- )
- assert user.is_staff
- assert user.is_superuser
-
- def test_user_str(self, db):
- """Test user string representation."""
- user = UserFactory(email='test@example.com')
- assert str(user) == 'test@example.com'
-
- class TestProductModel:
- """Test Product model."""
-
- def test_product_creation(self, db):
- """Test creating a product."""
- product = ProductFactory()
- assert product.id is not None
- assert product.is_active is True
- assert product.created_at is not None
-
- def test_product_slug_generation(self, db):
- """Test automatic slug generation."""
- product = ProductFactory(name='Test Product')
- assert product.slug == 'test-product'
-
- def test_product_price_validation(self, db):
- """Test price cannot be negative."""
- product = ProductFactory(price=-10)
- with pytest.raises(ValidationError):
- product.full_clean()
-
- def test_product_manager_active(self, db):
- """Test active manager method."""
- ProductFactory.create_batch(5, is_active=True)
- ProductFactory.create_batch(3, is_active=False)
-
- active_count = Product.objects.active().count()
- assert active_count == 5
-
- def test_product_stock_management(self, db):
- """Test stock management."""
- product = ProductFactory(stock=10)
- product.reduce_stock(5)
- product.refresh_from_db()
- assert product.stock == 5
-
- with pytest.raises(ValueError):
- product.reduce_stock(10) # Not enough stock
- ```
-
- ## View Testing
-
- ### Django View Testing
-
- ```python
- # tests/test_views.py
- import pytest
- from django.urls import reverse
- from tests.factories import ProductFactory, UserFactory
-
- class TestProductViews:
- """Test product views."""
-
- def test_product_list(self, client, db):
- """Test product list view."""
- ProductFactory.create_batch(10)
-
- response = client.get(reverse('products:list'))
-
- assert response.status_code == 200
- assert len(response.context['products']) == 10
-
- def test_product_detail(self, client, db):
- """Test product detail view."""
- product = ProductFactory()
-
- response = client.get(reverse('products:detail', kwargs={'slug': product.slug}))
-
- assert response.status_code == 200
- assert response.context['product'] == product
-
- def test_product_create_requires_login(self, client, db):
- """Test product creation requires authentication."""
- response = client.get(reverse('products:create'))
-
- assert response.status_code == 302
- assert response.url.startswith('/accounts/login/')
-
- def test_product_create_authenticated(self, authenticated_client, db):
- """Test product creation as authenticated user."""
- response = authenticated_client.get(reverse('products:create'))
-
- assert response.status_code == 200
-
- def test_product_create_post(self, authenticated_client, db, category):
- """Test creating a product via POST."""
- data = {
- 'name': 'Test Product',
- 'description': 'A test product',
- 'price': '99.99',
- 'stock': 10,
- 'category': category.id,
- }
-
- response = authenticated_client.post(reverse('products:create'), data)
-
- assert response.status_code == 302
- assert Product.objects.filter(name='Test Product').exists()
- ```
-
- ## DRF API Testing
-
- ### Serializer Testing
-
- ```python
- # tests/test_serializers.py
- import pytest
- from rest_framework.exceptions import ValidationError
- from apps.products.serializers import ProductSerializer
- from tests.factories import ProductFactory
-
- class TestProductSerializer:
- """Test ProductSerializer."""
-
- def test_serialize_product(self, db):
- """Test serializing a product."""
- product = ProductFactory()
- serializer = ProductSerializer(product)
-
- data = serializer.data
-
- assert data['id'] == product.id
- assert data['name'] == product.name
- assert data['price'] == str(product.price)
-
- def test_deserialize_product(self, db):
- """Test deserializing product data."""
- data = {
- 'name': 'Test Product',
- 'description': 'Test description',
- 'price': '99.99',
- 'stock': 10,
- 'category': 1,
- }
-
- serializer = ProductSerializer(data=data)
-
- assert serializer.is_valid()
- product = serializer.save()
-
- assert product.name == 'Test Product'
- assert float(product.price) == 99.99
-
- def test_price_validation(self, db):
- """Test price validation."""
- data = {
- 'name': 'Test Product',
- 'price': '-10.00',
- 'stock': 10,
- }
-
- serializer = ProductSerializer(data=data)
-
- assert not serializer.is_valid()
- assert 'price' in serializer.errors
-
- def test_stock_validation(self, db):
- """Test stock cannot be negative."""
- data = {
- 'name': 'Test Product',
- 'price': '99.99',
- 'stock': -5,
- }
-
- serializer = ProductSerializer(data=data)
-
- assert not serializer.is_valid()
- assert 'stock' in serializer.errors
- ```
-
- ### API ViewSet Testing
-
- ```python
- # tests/test_api.py
- import pytest
- from rest_framework.test import APIClient
- from rest_framework import status
- from django.urls import reverse
- from tests.factories import ProductFactory, UserFactory
-
- class TestProductAPI:
- """Test Product API endpoints."""
-
- @pytest.fixture
- def api_client(self):
- """Return API client."""
- return APIClient()
-
- def test_list_products(self, api_client, db):
- """Test listing products."""
- ProductFactory.create_batch(10)
-
- url = reverse('api:product-list')
- response = api_client.get(url)
-
- assert response.status_code == status.HTTP_200_OK
- assert response.data['count'] == 10
-
- def test_retrieve_product(self, api_client, db):
- """Test retrieving a product."""
- product = ProductFactory()
-
- url = reverse('api:product-detail', kwargs={'pk': product.id})
- response = api_client.get(url)
-
- assert response.status_code == status.HTTP_200_OK
- assert response.data['id'] == product.id
-
- def test_create_product_unauthorized(self, api_client, db):
- """Test creating product without authentication."""
- url = reverse('api:product-list')
- data = {'name': 'Test Product', 'price': '99.99'}
-
- response = api_client.post(url, data)
-
- assert response.status_code == status.HTTP_401_UNAUTHORIZED
-
- def test_create_product_authorized(self, authenticated_api_client, db):
- """Test creating product as authenticated user."""
- url = reverse('api:product-list')
- data = {
- 'name': 'Test Product',
- 'description': 'Test',
- 'price': '99.99',
- 'stock': 10,
- }
-
- response = authenticated_api_client.post(url, data)
-
- assert response.status_code == status.HTTP_201_CREATED
- assert response.data['name'] == 'Test Product'
-
- def test_update_product(self, authenticated_api_client, db):
- """Test updating a product."""
- product = ProductFactory(created_by=authenticated_api_client.user)
-
- url = reverse('api:product-detail', kwargs={'pk': product.id})
- data = {'name': 'Updated Product'}
-
- response = authenticated_api_client.patch(url, data)
-
- assert response.status_code == status.HTTP_200_OK
- assert response.data['name'] == 'Updated Product'
-
- def test_delete_product(self, authenticated_api_client, db):
- """Test deleting a product."""
- product = ProductFactory(created_by=authenticated_api_client.user)
-
- url = reverse('api:product-detail', kwargs={'pk': product.id})
- response = authenticated_api_client.delete(url)
-
- assert response.status_code == status.HTTP_204_NO_CONTENT
-
- def test_filter_products_by_price(self, api_client, db):
- """Test filtering products by price."""
- ProductFactory(price=50)
- ProductFactory(price=150)
-
- url = reverse('api:product-list')
- response = api_client.get(url, {'price_min': 100})
-
- assert response.status_code == status.HTTP_200_OK
- assert response.data['count'] == 1
-
- def test_search_products(self, api_client, db):
- """Test searching products."""
- ProductFactory(name='Apple iPhone')
- ProductFactory(name='Samsung Galaxy')
-
- url = reverse('api:product-list')
- response = api_client.get(url, {'search': 'Apple'})
-
- assert response.status_code == status.HTTP_200_OK
- assert response.data['count'] == 1
- ```
-
- ## Mocking and Patching
-
- ### Mocking External Services
-
- ```python
- # tests/test_views.py
- from unittest.mock import patch, Mock
- import pytest
-
- class TestPaymentView:
- """Test payment view with mocked payment gateway."""
-
- @patch('apps.payments.services.stripe')
- def test_successful_payment(self, mock_stripe, client, user, product):
- """Test successful payment with mocked Stripe."""
- # Configure mock
- mock_stripe.Charge.create.return_value = {
- 'id': 'ch_123',
- 'status': 'succeeded',
- 'amount': 9999,
- }
-
- client.force_login(user)
- response = client.post(reverse('payments:process'), {
- 'product_id': product.id,
- 'token': 'tok_visa',
- })
-
- assert response.status_code == 302
- mock_stripe.Charge.create.assert_called_once()
-
- @patch('apps.payments.services.stripe')
- def test_failed_payment(self, mock_stripe, client, user, product):
- """Test failed payment."""
- mock_stripe.Charge.create.side_effect = Exception('Card declined')
-
- client.force_login(user)
- response = client.post(reverse('payments:process'), {
- 'product_id': product.id,
- 'token': 'tok_visa',
- })
-
- assert response.status_code == 302
- assert 'error' in response.url
- ```
-
- ### Mocking Email Sending
-
- ```python
- # tests/test_email.py
- from django.core import mail
- from django.test import override_settings
-
- @override_settings(EMAIL_BACKEND='django.core.mail.backends.locmem.EmailBackend')
- def test_order_confirmation_email(db, order):
- """Test order confirmation email."""
- order.send_confirmation_email()
-
- assert len(mail.outbox) == 1
- assert order.user.email in mail.outbox[0].to
- assert 'Order Confirmation' in mail.outbox[0].subject
- ```
-
- ## Integration Testing
-
- ### Full Flow Testing
-
- ```python
- # tests/test_integration.py
- import pytest
- from django.urls import reverse
- from tests.factories import UserFactory, ProductFactory
-
- class TestCheckoutFlow:
- """Test complete checkout flow."""
-
- def test_guest_to_purchase_flow(self, client, db):
- """Test complete flow from guest to purchase."""
- # Step 1: Register
- response = client.post(reverse('users:register'), {
- 'email': 'test@example.com',
- 'password': 'testpass123',
- 'password_confirm': 'testpass123',
- })
- assert response.status_code == 302
-
- # Step 2: Login
- response = client.post(reverse('users:login'), {
- 'email': 'test@example.com',
- 'password': 'testpass123',
- })
- assert response.status_code == 302
-
- # Step 3: Browse products
- product = ProductFactory(price=100)
- response = client.get(reverse('products:detail', kwargs={'slug': product.slug}))
- assert response.status_code == 200
-
- # Step 4: Add to cart
- response = client.post(reverse('cart:add'), {
- 'product_id': product.id,
- 'quantity': 1,
- })
- assert response.status_code == 302
-
- # Step 5: Checkout
- response = client.get(reverse('checkout:review'))
- assert response.status_code == 200
- assert product.name in response.content.decode()
-
- # Step 6: Complete purchase
- with patch('apps.checkout.services.process_payment') as mock_payment:
- mock_payment.return_value = True
- response = client.post(reverse('checkout:complete'))
-
- assert response.status_code == 302
- assert Order.objects.filter(user__email='test@example.com').exists()
- ```
-
## Testing Best Practices
### DO
- **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`
### DON'T
- **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
- ## Coverage
-
- ### Coverage Configuration
-
- ```bash
- # Run tests with coverage
- pytest --cov=apps --cov-report=html --cov-report=term-missing
-
- # Generate HTML report
- open htmlcov/index.html
- ```
-
- ### Coverage Goals
-
- | Component | Target Coverage |
- |-----------|-----------------|
- | Models | 90%+ |
- | Serializers | 85%+ |
- | Views | 80%+ |
- | Services | 90%+ |
- | Utilities | 80%+ |
- | Overall | 80%+ |
-
## 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 |
Remember: Tests are documentation. Good tests explain how your code should work. Keep them simple, readable, and maintainable.
-
## Testing Pitfalls
### Mocking `connections` imported at module scope
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.
### `patch.object` cannot mock dunders on instances
`patch.object(connections, "__getitem__", ...)` silently fails. Mock at the class level (`patch.object(type(connections), "__getitem__", ...)`) or substitute a wrapper object that defines `__getitem__`.
### Always use timezone-aware datetimes
Naive datetimes trigger `RuntimeWarning` and, when `filterwarnings = ["error"]` is set, fail the suite. Use `django.utils.timezone.now()` or `timezone.make_aware(datetime(...))`.
```python
from django.utils import timezone
# WRONG
started_at = datetime(2025, 1, 1, 12, 0, 0)
# RIGHT
started_at = timezone.now()
started_at = timezone.make_aware(datetime(2025, 1, 1, 12, 0, 0))
```
### `auto_now_add` in tests
A `DateTimeField(auto_now_add=True)` ignores values passed to the constructor. Set the value after `save()`, not before:
```python
invoice = Invoice(customer=c)
invoice.save()
invoice.issued_at = some_time
invoice.save()
```
+
+ ## 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