testing

Django Testing Strategy

A production testing strategy for Django projects. Covers unit tests, integration tests, model testing, view testing, factory patterns, fixtures, coverage, and CI integration.

⏱ 14 min read intermediate
Terminal output showing Django test runner with passing tests

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.