π οΈ Django Testing Tools
Django ships with a full testing toolkit built on Python's unittest but tuned for web apps: test-case classes that manage a throwaway database, a Client that acts like a browser, and helpers for models, views, forms, URLs, and the admin. This lesson shows you how to test a Django project across every layer β and how to run it all under pytest.
π― Learning Objectives
By the end of this lesson, you will be able to:
- Choose the right base class among SimpleTestCase, TestCase, and TransactionTestCase
- Test models β CRUD, methods, validation, and relationships β against the test database
- Use the test Client and
reverse()to test views, status codes, templates, and context - Test forms and URLs, and verify authentication and permission behavior
- Set up realistic data with factory_boy and run Django tests under pytest-django
Estimated Time: 50β60 minutes β’ Difficulty: Intermediate
Hands-on: Write a model-and-view test suite for a small blog application.
In This Lesson
Why Django Has Its Own Tools
A plain function test can't easily talk to a database, render a template, or simulate a logged-in user hitting a URL. Django's test framework wraps unittest and adds exactly those web-app conveniences: it creates a separate test database, wraps each test in a transaction that is rolled back afterward, and gives you a Client that issues fake HTTP requests through your real URL routing and views.
π‘ Analogy: Testing a Django app is like quality control in a car factory. You check individual parts (models β unit tests), how parts fit together (views hitting models β integration tests), and finally take the assembled car for a drive (a full request/response through the Client).
Setup & Running Tests
startapp creates a single tests.py, but for anything beyond a toy app, replace it with a tests/ package so each concern gets its own file:
my_project/
βββ manage.py
βββ my_app/
βββ models.py
βββ views.py
βββ forms.py
βββ tests/
βββ __init__.py
βββ test_models.py
βββ test_views.py
βββ test_forms.py
Run tests with the test management command. Django discovers any test_* method on a TestCase subclass:
python manage.py test # everything
python manage.py test my_app # one app
python manage.py test my_app.tests.test_models # one module
python manage.py test --tag=fast # tests tagged @tag("fast")
python manage.py test --parallel # run across CPU cores
β Django handles the test database for you
- A dedicated test database is created before the run and destroyed after β your real data is never touched.
- Each
TestCasetest runs in a transaction that is rolled back, so tests start from a clean slate and stay isolated. - Email is routed to an in-memory outbox, and a fast password hasher is used automatically.
You can speed things up further by using an in-memory database and a fast hasher in your settings while testing:
# settings.py (test-only tweaks)
import sys
if "test" in sys.argv:
PASSWORD_HASHERS = ["django.contrib.auth.hashers.MD5PasswordHasher"]
DATABASES["default"] = {
"ENGINE": "django.db.backends.sqlite3",
"NAME": ":memory:",
}
The Test-Case Classes
Django layers several test-case classes on top of unittest.TestCase. Picking the right one keeps your suite fast:
| Class | Database? | Use when⦠|
|---|---|---|
SimpleTestCase | No (blocked) | Testing pure logic, template rendering, or simple views with no DB |
TestCase | Yes, rolled back per test | Almost everything β the default choice |
TransactionTestCase | Yes, truncated per test | Testing transaction.atomic(), savepoints, or select_for_update |
LiveServerTestCase | Yes, plus a live server | Browser automation (Selenium/Playwright) |
SimpleTestCase is fast because it refuses database access β reach for it when you don't need one:
from django.test import SimpleTestCase
from myapp.utils import normalize_email
class UtilityTests(SimpleTestCase):
def test_normalize_email(self):
self.assertEqual(normalize_email("User@EXAMPLE.com"), "user@example.com")
TestCase is what you'll use most. Its per-test transaction rollback is far faster than truncating tables, which is why it beats TransactionTestCase unless you specifically need to test transaction behavior:
from django.test import TestCase
from myapp.models import Product
class ProductModelTests(TestCase):
def setUp(self):
self.product = Product.objects.create(name="Test Product", price=19.99)
def test_str(self):
self.assertEqual(str(self.product), "Test Product")
Testing Models
Model tests focus on the behavior you added: custom methods, validation rules, relationships, and manager queries. Django's plumbing (saving a row, a plain field) rarely needs its own test β test the logic, not the framework.
from django.test import TestCase
from django.core.exceptions import ValidationError
from myapp.models import Product, Category
class ProductModelTests(TestCase):
def setUp(self):
self.category = Category.objects.create(name="Electronics")
self.product = Product.objects.create(
name="Smartphone", price=599.99, category=self.category, in_stock=True,
)
def test_created_and_counted(self):
self.assertEqual(Product.objects.count(), 1)
self.assertEqual(self.product.name, "Smartphone")
def test_str_representation(self):
self.assertEqual(str(self.product), "Smartphone")
def test_relationships(self):
self.assertEqual(self.product.category, self.category)
# reverse relation via related_name="products"
self.assertIn(self.product, self.category.products.all())
def test_validation_rejects_negative_price(self):
product = Product(name="Bad", price=-10, category=self.category)
with self.assertRaises(ValidationError):
product.full_clean() # validators run on full_clean(), not save()
def test_custom_manager_method(self):
self.assertEqual(Product.objects.in_stock().count(), 1)
self.product.in_stock = False
self.product.save()
self.assertEqual(Product.objects.in_stock().count(), 0)
β οΈ save() does not validate
Django does not run field validators on save(). To test validation you must call full_clean() explicitly (which is what ModelForm does under the hood). A test that expects save() to reject bad data will pass for the wrong reason.
Testing Views with the Client
The test Client simulates a browser: it sends a request through your URL config and views and hands back the response β without a running server. Always build URLs with reverse() so a change to a route doesn't silently break every test.
from django.test import TestCase
from django.urls import reverse
from django.contrib.auth import get_user_model
from myapp.models import Product
User = get_user_model()
class ProductViewTests(TestCase):
def setUp(self):
self.user = User.objects.create_user("ray", "ray@example.com", "pw12345")
self.product = Product.objects.create(name="Test Product", price=19.99)
self.list_url = reverse("product-list")
self.create_url = reverse("product-create")
def test_list_view(self):
response = self.client.get(self.list_url)
self.assertEqual(response.status_code, 200)
self.assertTemplateUsed(response, "myapp/product_list.html")
self.assertContains(response, "Test Product")
self.assertEqual(len(response.context["products"]), 1)
def test_create_requires_login(self):
# anonymous users are redirected to the login page
response = self.client.get(self.create_url)
self.assertEqual(response.status_code, 302)
def test_create_when_logged_in(self):
self.client.login(username="ray", password="pw12345")
response = self.client.post(self.create_url, {
"name": "New Product", "price": 29.99,
})
self.assertEqual(response.status_code, 302) # redirect on success
self.assertTrue(Product.objects.filter(name="New Product").exists())
π Handy Client assertions
assertContains(response, text) β text appears in the rendered body (and status is 200).
assertTemplateUsed(response, name) β the response rendered that template.
assertRedirects(response, url) β the response redirected to url.
response.context["key"] β inspect the data the view passed to the template.
For a JSON API built with Django REST Framework, use its APITestCase and DRF status constants:
from rest_framework import status
from rest_framework.test import APITestCase
from django.urls import reverse
from myapp.models import Product
class ProductAPITests(APITestCase):
def setUp(self):
self.product = Product.objects.create(name="Test Product", price=19.99)
def test_list(self):
response = self.client.get(reverse("api:product-list"))
self.assertEqual(response.status_code, status.HTTP_200_OK)
self.assertEqual(len(response.data), 1)
def test_create(self):
response = self.client.post(reverse("api:product-list"),
{"name": "New", "price": 39.99})
self.assertEqual(response.status_code, status.HTTP_201_CREATED)
self.assertEqual(Product.objects.count(), 2)
Forms, URLs & Auth
Forms
Form tests check validation and cleaning by instantiating the form with a data dict and asserting on is_valid() and errors:
from django.test import TestCase
from myapp.forms import ProductForm
from myapp.models import Category
class ProductFormTests(TestCase):
def setUp(self):
self.category = Category.objects.create(name="Electronics")
self.valid_data = {"name": "Widget", "price": 29.99, "category": self.category.id}
def test_valid(self):
self.assertTrue(ProductForm(data=self.valid_data).is_valid())
def test_missing_required_fields(self):
form = ProductForm(data={})
self.assertFalse(form.is_valid())
self.assertIn("name", form.errors)
self.assertIn("price", form.errors)
def test_negative_price_rejected(self):
form = ProductForm(data={**self.valid_data, "price": -5})
self.assertFalse(form.is_valid())
self.assertIn("price", form.errors)
URLs
reverse() turns a URL name into a path; resolve() turns a path back into the view. Testing both directions guards against accidental route changes:
from django.test import SimpleTestCase
from django.urls import reverse, resolve
from myapp.views import ProductListView
class UrlTests(SimpleTestCase):
def test_list_url_resolves(self):
url = reverse("product-list")
self.assertEqual(url, "/products/")
self.assertEqual(resolve(url).func.__name__, ProductListView.as_view().__name__)
Authentication & permissions
Log a user in with self.client.login(...) and assert that protected views redirect anonymous users and admin-only pages block regular ones:
from django.test import TestCase
from django.urls import reverse
from django.contrib.auth import get_user_model
User = get_user_model()
class AuthTests(TestCase):
def setUp(self):
self.user = User.objects.create_user("ray", password="pw12345")
self.protected_url = reverse("profile")
def test_redirects_anonymous(self):
response = self.client.get(self.protected_url)
self.assertEqual(response.status_code, 302)
def test_allows_logged_in(self):
self.client.login(username="ray", password="pw12345")
response = self.client.get(self.protected_url)
self.assertEqual(response.status_code, 200)
β Security is testable too
Django auto-escapes template output, so a value like <script> renders as harmless <script>. You can assert that the raw tag is not present in a response to lock in XSS protection, and enable enforce_csrf_checks=True on the Client to verify CSRF handling.
Test Data with factory_boy
setUp works, but hand-writing every object gets tedious and couples tests to unrelated field details. factory_boy defines a factory once and generates fresh, valid objects on demand β filling in sensible defaults so each test only states what it actually cares about.
# factories.py
import factory
from django.contrib.auth import get_user_model
from myapp.models import Category, Product
class UserFactory(factory.django.DjangoModelFactory):
class Meta:
model = get_user_model()
username = factory.Sequence(lambda n: f"user{n}")
email = factory.LazyAttribute(lambda o: f"{o.username}@example.com")
class CategoryFactory(factory.django.DjangoModelFactory):
class Meta:
model = Category
name = factory.Sequence(lambda n: f"Category {n}")
class ProductFactory(factory.django.DjangoModelFactory):
class Meta:
model = Product
name = factory.Sequence(lambda n: f"Product {n}")
price = factory.Faker("pydecimal", left_digits=3, right_digits=2, positive=True)
category = factory.SubFactory(CategoryFactory) # builds a Category automatically
in_stock = True
# in a test
from myapp.factories import ProductFactory
class ProductListTests(TestCase):
def test_lists_all_products(self):
ProductFactory.create_batch(10) # ten valid products, one line
response = self.client.get(reverse("product-list"))
self.assertEqual(len(response.context["products"]), 10)
Sequence guarantees unique values, SubFactory builds related objects for you, and Faker produces realistic fake data β together they keep test setup short and intention-revealing.
Running Under pytest-django
Many teams keep Django's test-case classes but run the whole suite with pytest to get its fixtures, parametrization, and cleaner output. The pytest-django plugin bridges the two:
pip install pytest-django
# pyproject.toml
[tool.pytest.ini_options]
DJANGO_SETTINGS_MODULE = "myproject.settings"
python_files = ["test_*.py", "*_test.py"]
Now you can write plain-function tests. The plugin provides a client fixture (a Django test Client) and an admin_client (already logged in as a superuser); mark any test that touches the database with @pytest.mark.django_db:
# conftest.py
import pytest
from myapp.models import Category, Product
@pytest.fixture
def category(db):
return Category.objects.create(name="Electronics")
@pytest.fixture
def product(category):
return Product.objects.create(name="Test Product", price=19.99, category=category)
# test_views.py
import pytest
from django.urls import reverse
@pytest.mark.django_db
def test_product_detail(client, product):
response = client.get(reverse("product-detail", args=[product.id]))
assert response.status_code == 200
assert "Test Product" in response.content.decode()
@pytest.mark.django_db
def test_edit_requires_login(client, admin_client, product):
url = reverse("product-edit", args=[product.id])
assert client.get(url).status_code == 302 # anonymous -> redirect
assert admin_client.get(url).status_code == 200 # superuser -> allowed
π‘ Best of both worlds
You don't have to rewrite existing TestCase classes β pytest runs them as-is. Adopt pytest-django gradually and write new tests as plain functions where fixtures and parametrize make them clearer.
Hands-on Exercise
ποΈ Test a blog application
Objective: Write model and view tests for a small blog. Here is the code under test:
# blog/models.py
from django.db import models
from django.conf import settings
from django.urls import reverse
class Post(models.Model):
title = models.CharField(max_length=200)
content = models.TextField()
author = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE)
published = models.BooleanField(default=False)
def __str__(self):
return self.title
def get_absolute_url(self):
return reverse("post-detail", args=[self.id])
# blog/views.py β PostListView hides unpublished posts from non-staff
class PostListView(ListView):
model = Post
context_object_name = "posts"
def get_queryset(self):
qs = super().get_queryset()
return qs if self.request.user.is_staff else qs.filter(published=True)
Your tasks
- Write a
TestCasethat creates a user and aPost, and assertstr(post)andget_absolute_url(). - Create one published and one unpublished post; assert an anonymous visit to the list shows only the published one.
- Assert that a staff user sees both posts.
π‘ Hint
Create the staff user with User.objects.create_user("boss", password="pw", is_staff=True). Use reverse("post-list") for the URL and check response.context["posts"] counts, or use assertContains / assertNotContains on the titles.
β Sample solution
# blog/tests/test_blog.py
from django.test import TestCase
from django.urls import reverse
from django.contrib.auth import get_user_model
from blog.models import Post
User = get_user_model()
class PostModelTests(TestCase):
def setUp(self):
self.author = User.objects.create_user("ray", password="pw12345")
self.post = Post.objects.create(
title="Hello", content="Body", author=self.author, published=True,
)
def test_str(self):
self.assertEqual(str(self.post), "Hello")
def test_absolute_url(self):
self.assertEqual(self.post.get_absolute_url(), f"/posts/{self.post.id}/")
class PostListViewTests(TestCase):
def setUp(self):
self.author = User.objects.create_user("ray", password="pw12345")
self.boss = User.objects.create_user("boss", password="pw12345", is_staff=True)
Post.objects.create(title="Live", content="x", author=self.author, published=True)
Post.objects.create(title="Draft", content="x", author=self.author, published=False)
self.url = reverse("post-list")
def test_anonymous_sees_only_published(self):
response = self.client.get(self.url)
self.assertEqual(len(response.context["posts"]), 1)
self.assertContains(response, "Live")
self.assertNotContains(response, "Draft")
def test_staff_sees_all(self):
self.client.login(username="boss", password="pw12345")
response = self.client.get(self.url)
self.assertEqual(len(response.context["posts"]), 2)
Best Practices
| β Do | π« Avoid |
|---|---|
Use TestCase by default; drop to SimpleTestCase when no DB is needed | Reaching for TransactionTestCase everywhere (it's much slower) |
Build URLs with reverse() | Hard-coding paths like "/products/1/" in tests |
| Test your own methods, validation, and permissions | Testing that Django itself saves a row |
| Use factories for concise, valid data | Copying huge create() blocks into every setUp |
Call full_clean() to test validation | Expecting save() to reject invalid data |
π‘ Watch your query counts. Wrap a view call in self.assertNumQueries(n) to catch N+1 query regressions early β a passing feature test can still hide a page that fires hundreds of queries.
Summary & Quiz
π Key Takeaways
- Django creates and tears down a separate test database, and
TestCaserolls back each test for isolation. - Pick SimpleTestCase (no DB), TestCase (default), or TransactionTestCase (transaction behavior) deliberately.
- The test Client plus
reverse()lets you assert status codes, templates, and context; usefull_clean()to test model validation. - factory_boy generates concise, valid test data with sequences, sub-factories, and Faker.
- pytest-django runs your existing tests and adds fixtures, parametrization, and cleaner output.
π― Quick Quiz
Question 1: Which base class should you use for a test that renders a template but needs no database?
Question 2: Why build URLs with reverse("product-list") instead of writing "/products/"?
Question 3: You want to assert a model rejects a negative price. Which call actually runs the validators?
π Further Reading
- Django β Testing overview
- Django β Testing tools (Client, assertions)
- pytest-django documentation
- factory_boy documentation
π What's Next?
Several tests above quietly mocked external services (payments, third-party APIs). Next we'll go deep on that skill β creating mocks, patching in the right place, and verifying calls β in Mocking and Patching in Python Tests.
π Nice work!
You can now test a Django app from its models all the way up to a full request/response.