Django Testing Guide: From Unit Tests to End-to-End Coverage

Django Testing Guide: From Unit Tests to End-to-End Coverage

Every Django project starts with the best intentions. You run manage.py startapp, write a few models, and think "I'll add tests later." Later arrives when a migration drops a column your views were querying, and you find out from a 500 error in production.

Django has one of the best built-in testing frameworks in any web framework. Most projects don't use it nearly enough.

Django's Testing Stack

Django ships with a full testing framework built on Python's unittest. You have two approaches:

Django's built-in TestCase:

from django.test import TestCase

class MyTest(TestCase):
    def test_something(self):
        self.assertEqual(1 + 1, 2)

pytest-django (recommended for modern projects):

pip install pytest pytest-django
# pytest.ini
[pytest]
DJANGO_SETTINGS_MODULE = myproject.settings.test

pytest-django gives you better fixture composition, cleaner assertion output, and access to the full pytest ecosystem. Most teams prefer it. This guide covers both.

Testing Models

Models contain your business logic. Test them directly, without HTTP.

# tests/test_models.py
import pytest
from django.test import TestCase
from decimal import Decimal
from shop.models import Order, OrderItem, Product

class TestOrderModel(TestCase):
    def setUp(self):
        self.product = Product.objects.create(
            name="Widget", 
            price=Decimal("29.99"),
            stock=100
        )
    
    def test_order_total_calculates_correctly(self):
        order = Order.objects.create(customer_email="test@example.com")
        OrderItem.objects.create(order=order, product=self.product, qty=3)
        
        assert order.total == Decimal("89.97")  # 3 × 29.99
    
    def test_order_total_applies_discount(self):
        order = Order.objects.create(
            customer_email="test@example.com",
            discount_percent=10
        )
        OrderItem.objects.create(order=order, product=self.product, qty=2)
        
        assert order.total == Decimal("53.98")  # (2 × 29.99) × 0.90
    
    def test_stock_decremented_on_order_completion(self):
        order = Order.objects.create(customer_email="test@example.com")
        OrderItem.objects.create(order=order, product=self.product, qty=5)
        
        order.complete()
        
        self.product.refresh_from_db()
        assert self.product.stock == 95
    
    def test_completing_order_with_insufficient_stock_raises(self):
        order = Order.objects.create(customer_email="test@example.com")
        OrderItem.objects.create(order=order, product=self.product, qty=200)
        
        with self.assertRaises(ValueError, msg="Insufficient stock"):
            order.complete()

Testing Views

Django's TestClient makes view testing straightforward. Test status codes, redirects, context, and rendered content.

# tests/test_views.py
from django.test import TestCase, Client
from django.urls import reverse
from django.contrib.auth import get_user_model

User = get_user_model()

class TestProductViews(TestCase):
    def setUp(self):
        self.client = Client()
        self.user = User.objects.create_user(
            username="testuser",
            password="testpass123"
        )
    
    def test_product_list_returns_200(self):
        response = self.client.get(reverse("products:list"))
        self.assertEqual(response.status_code, 200)
    
    def test_product_list_includes_products_in_context(self):
        Product.objects.create(name="Widget", price=29.99, stock=10)
        Product.objects.create(name="Gadget", price=49.99, stock=5)
        
        response = self.client.get(reverse("products:list"))
        
        self.assertIn("products", response.context)
        self.assertEqual(response.context["products"].count(), 2)
    
    def test_product_detail_404_for_missing_product(self):
        response = self.client.get(reverse("products:detail", args=[99999]))
        self.assertEqual(response.status_code, 404)
    
    def test_checkout_redirects_anonymous_user_to_login(self):
        response = self.client.get(reverse("checkout:start"))
        self.assertRedirects(response, f"/login/?next={reverse('checkout:start')}")
    
    def test_checkout_accessible_to_authenticated_user(self):
        self.client.login(username="testuser", password="testpass123")
        response = self.client.get(reverse("checkout:start"))
        self.assertEqual(response.status_code, 200)

Testing Django REST Framework APIs

If you're using DRF, use APITestCase and APIClient:

from rest_framework.test import APITestCase, APIClient
from rest_framework import status
from django.urls import reverse
from django.contrib.auth import get_user_model

User = get_user_model()

class TestUserAPI(APITestCase):
    def setUp(self):
        self.user = User.objects.create_user(
            username="apiuser",
            email="api@test.com",
            password="pass123"
        )
    
    def test_list_users_requires_auth(self):
        response = self.client.get(reverse("api:users-list"))
        self.assertEqual(response.status_code, status.HTTP_401_UNAUTHORIZED)
    
    def test_list_users_returns_paginated_results(self):
        self.client.force_authenticate(user=self.user)
        response = self.client.get(reverse("api:users-list"))
        
        self.assertEqual(response.status_code, status.HTTP_200_OK)
        self.assertIn("results", response.data)
        self.assertIn("count", response.data)
    
    def test_create_user_validates_email_uniqueness(self):
        self.client.force_authenticate(user=self.user)
        response = self.client.post(reverse("api:users-list"), {
            "username": "newuser",
            "email": "api@test.com",  # Already taken
            "password": "pass123"
        })
        self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
        self.assertIn("email", response.data)
    
    def test_update_user_requires_ownership(self):
        other_user = User.objects.create_user(username="other", password="pass123")
        self.client.force_authenticate(user=self.user)
        
        response = self.client.patch(
            reverse("api:users-detail", args=[other_user.id]),
            {"username": "hijacked"}
        )
        self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN)

Testing Forms

Django forms have validation logic that deserves explicit tests:

# tests/test_forms.py
from django.test import TestCase
from shop.forms import CheckoutForm

class TestCheckoutForm(TestCase):
    def get_valid_data(self):
        return {
            "email": "buyer@example.com",
            "shipping_address": "123 Main St",
            "city": "Springfield",
            "postal_code": "12345",
            "card_number": "4111111111111111",
        }
    
    def test_valid_form_is_valid(self):
        form = CheckoutForm(data=self.get_valid_data())
        self.assertTrue(form.is_valid())
    
    def test_invalid_email_makes_form_invalid(self):
        data = self.get_valid_data()
        data["email"] = "not-an-email"
        form = CheckoutForm(data=data)
        self.assertFalse(form.is_valid())
        self.assertIn("email", form.errors)
    
    def test_missing_required_field_raises_error(self):
        data = self.get_valid_data()
        del data["shipping_address"]
        form = CheckoutForm(data=data)
        self.assertFalse(form.is_valid())
        self.assertIn("shipping_address", form.errors)

Testing with pytest-django Fixtures

pytest-django makes database setup cleaner with fixtures:

# conftest.py
import pytest
from django.contrib.auth import get_user_model

User = get_user_model()

@pytest.fixture
def user(db):
    return User.objects.create_user(
        username="fixture_user",
        email="fixture@test.com",
        password="testpass123"
    )

@pytest.fixture
def authenticated_client(client, user):
    client.force_login(user)
    return client

@pytest.fixture
def product(db):
    from shop.models import Product
    return Product.objects.create(name="Test Widget", price=19.99, stock=50)
# tests/test_with_fixtures.py
import pytest
from django.urls import reverse

@pytest.mark.django_db
def test_add_to_cart_requires_login(client, product):
    response = client.post(reverse("cart:add", args=[product.id]))
    assert response.status_code == 302  # Redirect to login

@pytest.mark.django_db
def test_add_to_cart_succeeds_when_authenticated(authenticated_client, product):
    response = authenticated_client.post(reverse("cart:add", args=[product.id]))
    assert response.status_code == 200
    data = response.json()
    assert data["cart_count"] == 1

@pytest.mark.django_db
def test_add_out_of_stock_product_to_cart_fails(authenticated_client, db):
    from shop.models import Product
    out_of_stock = Product.objects.create(name="Rare Item", price=99.99, stock=0)
    
    response = authenticated_client.post(reverse("cart:add", args=[out_of_stock.id]))
    assert response.status_code == 400

Testing Celery Tasks

If you use Celery for background jobs, test task logic directly:

# tests/test_tasks.py
import pytest
from unittest.mock import patch, MagicMock
from shop.tasks import send_order_confirmation, process_refund

class TestOrderTasks(TestCase):
    @patch("shop.tasks.send_mail")
    def test_confirmation_email_sent_with_correct_data(self, mock_send):
        order = Order.objects.create(
            customer_email="buyer@test.com",
            total=49.99
        )
        
        send_order_confirmation(order.id)
        
        mock_send.assert_called_once()
        call_kwargs = mock_send.call_args[1]
        assert "buyer@test.com" in call_kwargs["recipient_list"]
        assert str(order.id) in call_kwargs["message"]
    
    @patch("shop.tasks.stripe.Refund.create")
    def test_refund_task_calls_stripe_with_correct_amount(self, mock_stripe):
        mock_stripe.return_value = MagicMock(id="re_test_123")
        
        process_refund(order_id=42, amount_cents=4999)
        
        mock_stripe.assert_called_once_with(
            payment_intent="pi_from_order_42",
            amount=4999
        )

What Your Test Suite Misses

A green Django test suite means the logic is correct. It doesn't mean the deployed application behaves correctly.

Production Django failures that escape tests:

  • SECRET_KEY rotation — a config change invalidates all existing sessions, logging out every user simultaneously
  • Database migration side effects — a migration runs fine in staging but acquires a table lock that times out under production load
  • Static file 404s — your collectstatic step fails silently, breaking CSS/JS on the frontend
  • Third-party API degradation — Stripe or SendGrid returns slower responses than your timeout setting
  • Cache invalidation bugs — a model update doesn't clear the cache, so users see stale data

Monitoring Django Apps in Production

HelpMeTest lets you write behavioral tests against your deployed Django site and run them continuously:

Test: checkout flow — authenticated user
Navigate to https://your-app.com/shop/
Click "Add to Cart" on first product
Navigate to /cart/
Click "Proceed to Checkout"
Verify: checkout form is visible
Verify: order total matches cart total

Write it once. HelpMeTest runs it on a schedule. If a migration breaks your checkout, a config change breaks auth, or a third-party widget starts returning 500s, you know immediately — not from a user complaint.

Free tier: 10 tests, unlimited health checks. Try HelpMeTest →

Django Testing Checklist

  • Model tests: business logic, computed fields, custom save methods, constraint violations
  • View tests: status codes, redirects, context variables, permission enforcement
  • Form tests: valid inputs, invalid inputs, boundary conditions
  • API tests (DRF): auth required, serializer validation, ownership rules
  • Celery task tests: task logic with mocked external calls
  • Signal tests: side effects fire correctly on model events
  • Integration tests: full request/response cycles through middleware
  • Production monitoring: behavioral tests running after every deploy

Django's testing framework is there. Write the tests.

Read more