| name | django-tdd |
| description | Django testing strategies with pytest-django, TDD methodology, factory_boy, mocking, coverage, and testing Django REST Framework APIs. |
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
- Setting up factory_boy factories to replace manual test data creation and database fixtures
- Writing integration tests for a complete user flow through multiple Django views or API endpoints
- Configuring pytest-django with
--reuse-db and --nomigrations to speed up a slow test suite
TDD Workflow for Django
Red-Green-Refactor Cycle
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
Setup
pytest.ini
[pytest]
DJANGO_SETTINGS_MODULE = config.settings.test
testpaths = tests
addopts = --reuse-db --nomigrations --cov=apps --cov-report=term-missing --strict-markers
markers =
slow: marks tests as slow
integration: marks tests as integration tests
config/settings/test.py (key overrides)
from .base import *
DATABASES = {'default': {'ENGINE': 'django.db.backends.sqlite3', 'NAME': ':memory:'}}
PASSWORD_HASHERS = ['django.contrib.auth.hashers.MD5PasswordHasher']
CELERY_TASK_ALWAYS_EAGER = True
CELERY_TASK_EAGER_PROPAGATES = True
conftest.py
import pytest
from django.contrib.auth import get_user_model
from rest_framework.test import APIClient
User = get_user_model()
@pytest.fixture
def user(db):
return User.objects.create_user(email='test@example.com', password='testpass123', username='testuser')
@pytest.fixture
def authenticated_client(client, user):
client.force_login(user)
return client
@pytest.fixture
def api_client():
return APIClient()
@pytest.fixture
def authenticated_api_client(api_client, user):
api_client.force_authenticate(user=user)
return api_client
Factory Boy
import factory
from factory import fuzzy
from django.contrib.auth import get_user_model
from apps.products.models import Product, Category
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')
is_active = True
class ProductFactory(factory.django.DjangoModelFactory):
class Meta:
model = Product
name = factory.Faker('sentence', nb_words=3)
slug = factory.LazyAttribute(lambda obj: obj.name.lower().replace(' ', '-'))
price = fuzzy.FuzzyDecimal(10.00, 1000.00, 2)
stock = fuzzy.FuzzyInteger(0, 100)
is_active = True
category = factory.SubFactory(lambda: CategoryFactory())
created_by = factory.SubFactory(UserFactory)
@factory.post_generation
def tags(self, create, extracted, **kwargs):
if create extracted:
tag extracted:
.tags.add(tag)
Key patterns: ProductFactory(price=99) overrides fields, ProductFactory.create_batch(10) for bulk, ProductFactory(tags=[t1, t2]) for M2M via @post_generation.
Model Testing
Model Tests
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():
product = ProductFactory()
product.
product.is_active
product.created_at
():
product = ProductFactory(name=)
product.slug ==
():
product = ProductFactory(price=-)
pytest.raises(ValidationError):
product.full_clean()
():
ProductFactory.create_batch(, is_active=)
ProductFactory.create_batch(, is_active=)
active_count = Product.objects.active().count()
active_count ==
():
product = ProductFactory(stock=)
product.reduce_stock()
product.refresh_from_db()
product.stock ==
pytest.raises(ValueError):
product.reduce_stock()
View Testing
Django View Testing
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):
response = authenticated_client.get(reverse())
response.status_code ==
():
data = {
: ,
: ,
: ,
: ,
: category.,
}
response = authenticated_client.post(reverse(), data)
response.status_code ==
Product.objects.(name=).exists()
DRF API Testing
Serializer Testing
def test_serialize_product(self, db):
product = ProductFactory()
data = ProductSerializer(product).data
assert data['id'] == product.id and data['name'] == product.name
def test_invalid_price(self, db):
serializer = ProductSerializer(data={'name': 'X', 'price': '-10', 'stock': 5})
assert not serializer.is_valid()
assert 'price' in serializer.errors
API ViewSet Testing
import pytest
from rest_framework import status
from django.urls import reverse
from tests.factories import ProductFactory
class TestProductAPI:
def test_list_products(self, api_client, db):
ProductFactory.create_batch(10)
response = api_client.get(reverse('api:product-list'))
assert response.status_code == status.HTTP_200_OK
assert response.data['count'] == 10
def test_create_product_unauthorized(self, api_client, db):
response = api_client.post(reverse('api:product-list'), {'name': 'X', 'price': '9.99'})
assert response.status_code == status.HTTP_401_UNAUTHORIZED
def test_create_product_authorized(self, authenticated_api_client, db):
data = {'name': 'Test Product', 'price': '99.99', 'stock': 10}
response = authenticated_api_client.post(reverse('api:product-list'), data)
assert response.status_code == status.HTTP_201_CREATED
assert response.data['name'] == 'Test Product'
def test_update_product():
product = ProductFactory(created_by=authenticated_api_client.user)
response = authenticated_api_client.patch(
reverse(, kwargs={: product.}), {: }
)
response.status_code == status.HTTP_200_OK
():
product = ProductFactory(created_by=authenticated_api_client.user)
response = authenticated_api_client.delete(
reverse(, kwargs={: product.})
)
response.status_code == status.HTTP_204_NO_CONTENT
():
ProductFactory(price=); ProductFactory(price=)
response = api_client.get(reverse(), {: })
response.data[] ==
Mocking and Patching
Mocking External Services
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."""
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 ==
response.url
Mocking Email Sending
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
Integration tests exercise a complete user journey across multiple views. Use client (Django test client) and mock only external I/O (payment gateways, emails).
def test_guest_to_purchase_flow(self, client, db):
client.post(reverse('users:register'), {'email': 'test@example.com', 'password': 'testpass123', 'password_confirm': 'testpass123'})
client.post(reverse('users:login'), {'email': 'test@example.com', 'password': 'testpass123'})
product = ProductFactory(price=100)
client.post(reverse('cart:add'), {'product_id': product.id, 'quantity': 1})
response = client.get(reverse('checkout:review'))
assert product.name in response.content.decode()
with patch('apps.checkout.services.process_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
pytest --cov=apps --cov-report=html --cov-report=term-missing
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.