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.testpytest-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 == 400Testing 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_KEYrotation — 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
collectstaticstep 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 totalWrite 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.