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
# 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
# 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
# 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
# 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
# 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
# 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
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-dband--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
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(...)).
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:
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