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 Patterns
Core Patterns with Failure Modes
Lazy attributes — computed values that depend on other fields:
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:
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:
category = factory.SubFactory(CategoryFactory)
Failure: Nested SubFactory creates database hits; tests slow down exponentially with depth.
django_get_or_create for M2M through models:
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:
@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:
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
# tests/factories.py
import factory
from factory import fuzzy
from django.contrib.auth import get_user_model
User = get_user_model()
class UserFactory(factory.django.DjangoModelFactory):
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')
class ProductFactory(factory.django.DjangoModelFactory):
class Meta:
model = Product
name = factory.Faker('sentence', nb_words=3)
price = fuzzy.FuzzyDecimal(10.00, 1000.00, 2)
category = factory.SubFactory(CategoryFactory)
@factory.post_generation
def tags(self, create, extracted, **kwargs):
if not create or not extracted:
return
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():
product = ProductFactory(price=100.00, stock=50)
assert product.price == 100.00
assert product.stock == 50
def test_multiple_products():
products = ProductFactory.create_batch(10)
assert len(products) == 10
Transaction and Isolation Testing
pytest.mark.django_db vs transaction=True
@pytest.mark.django_db wraps each test in a transaction and rolls back at the end. Fast, but:
- 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)
transaction=True disables transaction wrapping and uses real transactions:
@pytest.mark.django_db(transaction=True)
def test_real_transaction():
# Actual commit/rollback behavior
pass
- 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
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 Pitfalls
Patching request.user on the class instead of instance
# WRONG — patches the attribute on the class, leaks across tests
@patch('django.contrib.auth.models.User')
def test_view(mock_user):
...
# 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.
Patching a manager method vs the queryset
# 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
# WRONG — decorator wraps the function; patch target is wrapper's module attribute
@patch('apps.views.my_view')
def test_view(mock_view):
...
# 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.
Using override_settings instead of mock.patch for settings evaluated at import time
# 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.
Mocking a third-party HTTP client with un-asserted mock
# 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
# 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
# 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
# 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
# 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():
invoice = Invoice(customer=c)
invoice.save()
invoice.issued_at = some_time
invoice.save()
Mutation Testing for Django
Use mutation testing to verify your tests actually catch logic errors. mutmut is the standard tool for Python — tool mechanics, configuration, and the cross-tool guide (mutmut, cargo-mutants, Stryker) are canonical in frameworks/pytest/references/mutation-testing.md; keep this section to Django-specific guidance.
Django-Specific Friction Points
Test-database cost dominates the run. Every mutant reruns pytest; a Django suite recreates or reuses a test database and re-runs migrations. Mitigations:
- Reuse the database:
--reuse-db for pytest-django or keepdb=True for create_test_db; pass --nomigrations via mutmut's pytest_add_cli_args so each mutant skips migration re-runs
- Prefer
TestCase over TransactionTestCase outside the few tests that genuinely need real transactions — TransactionTestCase flushes after each test, recreating content types and permissions per model. Cost grows with model count.
The DEBUG blind spot. DiscoverRunner takes debug_mode=False and its setup_test_environment() sets DEBUG to self.debug_mode, which defaults to False. Therefore code guarded by if settings.DEBUG: never executes under the test runner unless the runner is given debug_mode=True (the --debug-mode test flag).
Consequence: mutants inside debug-only branches survive for a structural reason, not a test-quality reason. The whole branch is effectively untested. Two honest options:
- Run the runner with
debug_mode=True for that subset
- Exclude the branches with
# pragma: no mutate and accept they are manually tested
Do not present this as a fix — it is a blind spot you must consciously choose about.
Mocking defeats mutation detection. A test that patches the very function under test, or asserts only on a mocked call, will not fail when the real logic is mutated — the mutant survives even though the test "covers" the line. This is the sharpest reason coverage and mutation disagree so sharply on Django codebases. Connect this to the mocking-pitfalls section above.
Fork isolation with a database in play. Mutmut's default process_isolation="fork" means each mutant worker inherits the pytest session's state, including open DB connections. The docs give forkserver as the fix for hangs, segfaults, or irreproducible results. Name that as the first thing to try when a Django mutation run hangs.
Sequencing with --parallel. Each parallel worker needs its own database; a suite that shares a resource must use django.test.testcases.SerializeMixin rather than assuming parallel safety.
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