A Django project without tests is a project that breaks quietly. Testing is not about achieving a coverage number. It is about building confidence that the behaviors your users depend on actually work, that deployments do not introduce regressions, and that refactoring is safe. This guide covers the testing strategy I apply to production Django projects: the test types that give the most value, model and view testing patterns, factory-based test data, fixture management, assertion techniques, and CI integration that catches problems before they reach production. For more on testing tools and patterns, see the Testing hub.
Most teams either test nothing or test the wrong things. Writing 200 unit tests for utility functions while ignoring the checkout flow is a recipe for false confidence. Start with the flows that generate revenue or trust, then work outward.
Test types and where they help
Unit tests verify isolated functions and methods. They run fast and catch logic errors:
from decimal import Decimal
from catalog.models import Product
def test_product_discount_calculation():
product = Product(price=Decimal('100.00'), discount_percent=15)
assert product.discounted_price == Decimal('85.00')
Integration tests verify that components work together: views, templates, middleware, and the database:
from django.test import TestCase
class ProductViewTests(TestCase):
def test_product_list_returns_200(self):
response = self.client.get('/products/')
self.assertEqual(response.status_code, 200)
self.assertTemplateUsed(response, 'catalog/product_list.html')
End-to-end tests verify full user workflows through a browser. Use Playwright or Selenium for critical paths like registration, login, and checkout. Keep these few and focused.
Using pytest with Django
While Django’s built-in test runner works, pytest with pytest-django is more ergonomic:
pip install pytest pytest-django
Create pytest.ini:
[pytest]
DJANGO_SETTINGS_MODULE = myproject.settings.test
python_files = tests.py test_*.py *_tests.py
Pytest offers simpler assertions, powerful fixtures, parameterized tests, and better output. It runs Django’s TestCase classes alongside plain test functions.
Model testing
Test model methods, properties, constraints, and the save pipeline:
import pytest
from catalog.models import Product
@pytest.mark.django_db
def test_product_slug_auto_generated():
product = Product.objects.create(name='Django Field Manual', price='49.99')
assert product.slug == 'django-field-manual'
@pytest.mark.django_db
def test_product_requires_positive_price():
with pytest.raises(Exception):
Product.objects.create(name='Invalid', price='-10.00')
Test the constraints and validations that protect data integrity. These tests catch problems early when someone changes a model field or overrides clean().
View testing
Test views through Django’s test client. Verify status codes, template usage, context data, and redirects:
from django.test import TestCase
from django.urls import reverse
class DashboardTests(TestCase):
def setUp(self):
self.user = User.objects.create_user('tester', password='testpass123')
def test_dashboard_requires_login(self):
response = self.client.get(reverse('dashboard'))
self.assertRedirects(response, '/login/?next=/dashboard/')
def test_dashboard_accessible_when_authenticated(self):
self.client.login(username='tester', password='testpass123')
response = self.client.get(reverse('dashboard'))
self.assertEqual(response.status_code, 200)
self.assertContains(response, 'Welcome')
Test both the happy path and the failure modes: unauthenticated access, missing objects, invalid form data, and permission denials.
Factory pattern for test data
Hard-coded test data creates brittle tests. Use factory_boy to generate realistic, reusable test objects:
import factory
from accounts.models import User
from catalog.models import Product
class UserFactory(factory.django.DjangoModelFactory):
class Meta:
model = User
username = factory.Sequence(lambda n: f'user_{n}')
email = factory.LazyAttribute(lambda o: f'{o.username}@example.com')
class ProductFactory(factory.django.DjangoModelFactory):
class Meta:
model = Product
name = factory.Faker('catch_phrase')
price = factory.Faker('pydecimal', left_digits=3, right_digits=2, positive=True)
Use factories in tests:
@pytest.mark.django_db
def test_product_list_shows_active_products():
ProductFactory.create_batch(3, is_active=True)
ProductFactory.create_batch(2, is_active=False)
response = client.get('/products/')
assert len(response.context['products']) == 3
Form and validation testing
Test form validation directly:
from catalog.forms import ProductForm
def test_product_form_rejects_negative_price():
form = ProductForm(data={'name': 'Test', 'price': '-5.00'})
assert not form.is_valid()
assert 'price' in form.errors
def test_product_form_accepts_valid_data():
form = ProductForm(data={'name': 'Test Product', 'price': '29.99', 'category': 'books'})
assert form.is_valid()
The forms and validation guide covers form patterns in more depth.
Testing with fixtures versus factories
Django fixtures load data from JSON or YAML files. They work but become fragile as models change. Factories are more resilient because they construct objects programmatically and adapt to model changes.
Use fixtures for:
- Reference data that rarely changes (countries, categories, permissions)
- Initial database state for large integration tests
Use factories for:
- Test-specific data
- Randomized data for property-based testing
- Any data that involves relationships
Test settings
Create a dedicated test settings module:
# settings/test.py
from .base import *
DEBUG = False
DATABASES = {
'default': {
'ENGINE': 'django.db.backends.sqlite3',
'NAME': ':memory:',
}
}
PASSWORD_HASHERS = ['django.contrib.auth.hashers.MD5PasswordHasher']
EMAIL_BACKEND = 'django.core.mail.backends.locmem.EmailBackend'
CELERY_TASK_ALWAYS_EAGER = True
Using in-memory SQLite and fast password hashers dramatically reduces test execution time. The locmem email backend captures sent emails in memory for assertion.
Coverage and CI
Track coverage with pytest-cov:
pytest --cov=myproject --cov-report=html
In CI, fail the build if coverage drops below a threshold:
pytest --cov=myproject --cov-fail-under=80
Target 80% coverage as a practical minimum. Do not chase 100%. The last 10% usually means testing Django internals or writing fragile tests for trivial code.
Frequently asked questions
How many tests should a Django project have? There is no right number. Focus on testing business-critical paths, data integrity constraints, and integration points. A project with 50 well-targeted tests is better than one with 500 tests that miss the checkout flow.
Should I test Django’s built-in views? No. Django’s views are already tested. Test your configuration of those views: custom templates, overridden methods, and permission settings.
How do I test tasks that run in Celery?
Set CELERY_TASK_ALWAYS_EAGER = True in test settings. Tasks execute synchronously and inline. Test the task function’s logic, not Celery’s task routing.
When should I use mocking? Mock external services, third-party APIs, and slow operations. Do not mock the database or Django internals. Tests that mock everything test nothing.