Testing Django apps
Write fast tests with TestCase, drive views through the test client, and check queries, redirects and form errors instead of just asserting 200.
TestCase and the test database
# blog/tests/test_models.py
from django.test import TestCase
from django.utils import timezone
from blog.models import Category, Post
class PostModelTests(TestCase):
@classmethod
def setUpTestData(cls): # runs once, wrapped in a class transaction
cls.author = get_user_model().objects.create_user("ana", password="pw12345678")
cls.news = Category.objects.create(name="News", slug="news")
def setUp(self): # runs before every test method
self.post = Post.objects.create(title="Hello", slug="hello", author=self.author)
def test_publish_sets_status_and_timestamp(self):
self.post.publish()
self.post.refresh_from_db()
self.assertEqual(self.post.status, Post.Status.PUBLISHED)
self.assertIsNotNone(self.post.published_at)
def test_draft_is_not_published(self):
self.assertEqual(self.post.status, Post.Status.DRAFT)- Django creates a test database (
test_<name>) on the first run, applies migrations, and destroys it at the end. Your development data is never touched. TestCasewraps each test in a transaction and rolls it back, so tests are isolated without deleting rows.TransactionTestCaseis needed only when you test transaction behaviour itself, and it is much slower.setUpTestDatabuilds shared objects once per class;setUpbuilds per-test data. PrefersetUpTestDatafor fixtures nobody mutates — it is the difference between a two-second and a twenty-second suite.- Files must be named
test*.py(or live in atests/package) for the runner to discover them. Run a subset withpython manage.py test blog.tests.test_models.PostModelTests.test_publish_sets_status_and_timestamp.
Testing views with the client
# blog/tests/test_views.py
from django.contrib.auth import get_user_model
from django.test import TestCase
from django.urls import reverse
from blog.models import Post
class PostViewTests(TestCase):
def setUp(self):
self.user = get_user_model().objects.create_user("ana", password="pw12345678")
self.post = Post.objects.create(title="Hello", slug="hello",
author=self.user, status="published")
def test_list_shows_published_post(self):
response = self.client.get(reverse("blog:list"))
self.assertEqual(response.status_code, 200)
self.assertContains(response, "Hello")
self.assertTemplateUsed(response, "blog/post_list.html")
def test_draft_returns_404_for_anonymous(self):
self.post.status = "draft"
self.post.save(update_fields=["status"])
self.assertEqual(self.client.get(self.post.get_absolute_url()).status_code, 404)
def test_login_redirects_to_next(self):
response = self.client.get(reverse("blog:drafts"))
self.assertRedirects(response, f"{reverse('login')}?next={reverse('blog:drafts')}")
def test_create_requires_auth_and_saves_author(self):
self.client.force_login(self.user) # bypasses the login form
response = self.client.post(reverse("blog:create"),
{"title": "New", "slug": "new", "body": "x" * 60})
self.assertEqual(response.status_code, 302)
self.assertEqual(Post.objects.get(slug="new").author, self.user)
def test_query_count_is_stable(self):
with self.assertNumQueries(2): # 1 posts + 1 authors
self.client.get(reverse("blog:list"))| Assertion | Checks |
|---|---|
assertContains(response, text) | Body contains text (and respects status) with status_code= |
assertRedirects(response, url) | 3xx target, following the chain by default |
assertTemplateUsed(response, name) | Which template rendered |
assertFormError(response, form, field, msg) | A specific validation message |
assertNumQueries(n) | Guards against an N+1 regression |
assertJSONEqual(response.content, obj) | JSON payload equality |
The client is a request simulator: no network, no server, full access to the response object. Use force_login() instead of posting credentials in every test, and Client(enforce_csrf_checks=True) when you specifically want to prove your form sends a token.
Forms, fixtures and coverage
# blog/tests/test_forms.py
from django.test import TestCase
from blog.forms import CommentForm
class CommentFormTests(TestCase):
def test_rejects_links(self):
form = CommentForm(data={"text": "see https://spam.example"})
self.assertFalse(form.is_valid())
self.assertIn("Links are not allowed", form.errors["text"][0])
def test_accepts_plain_text(self):
form = CommentForm(data={"text": "Great post, thanks."})
self.assertTrue(form.is_valid(), form.errors)# blog/tests/factories.py — explicit builders beat a 4000-line fixture JSON
import factory
from blog.models import Post
class PostFactory(factory.django.DjangoModelFactory):
class Meta:
model = Post
title = factory.Sequence(lambda n: f"Post {n}")
slug = factory.Sequence(lambda n: f"post-{n}")
author = factory.SubFactory(UserFactory)
# usage
posts = PostFactory.create_batch(3, status="published")python manage.py test --parallel --keepdb # faster local runs
coverage run manage.py test && coverage report --skip-covered
pytest # with pytest-django + DJANGO_SETTINGS_MODULE⚠️
A test that asserts only
status_code == 200 passes for an empty page, a wrong template and a page showing another user's data. Assert the observable contract: the status, the content that must appear, the redirect target, the row that must exist afterwards, and the query count you refuse to exceed. Equally, do not test Django's own machinery — test your logic, your permissions and your rendering.FAQ
How do I test code that sends email or calls an external API?
Replace the boundary, not the logic. Django ships
mail.outbox for email, and unittest.mock.patch handles HTTP clients. Assert what your code did with the result, and keep one integration test that runs against a real service on demand rather than in every commit.Fixtures or factories?
Factories for almost everything: they are readable, only create the rows you need, and survive model changes without a regeneration step. Keep a small fixture only for genuine reference data such as countries or permission groups that you also want to load into production.
Related
Django REST Framework: serializers and viewsets Authentication, users and permissions
Last refreshed 2026-09-18.