Testing
|
This content was generated with the assistance of AI and should be verified against the official documentation before being relied on in production. The reference material consulted while preparing these pages is listed in the section bibliography. |
This page is written against Django 6.1 (6.1.1 on PyPI, released 2026-09-02; 6.1.2 is scheduled for 2026-10-06), Python
3.12-3.14 and PostgreSQL 15 or newer, all checked on 2026-10-03; where Django 5.2 LTS behaves differently the text says
so. Every listing below was run in a scratch project on Python 3.13 and Django 6.1.1 with SQLite: manage.py check
(also under -W error) passes and the suite of 31 tests the page builds passes with the default runner, with
--parallel 4, with --shuffle and with a dedicated test settings module; the runner output shown is copied from those
runs. Django ships a test framework built on Python’s standard unittest: test case classes that isolate the database,
a fake browser (the test client) that drives the whole request/response cycle without a server, a request factory for
calling views directly, assertions that understand templates, redirects, forms and SQL, and a runner that builds a
throw-away database, redirects outgoing email to memory and can run tests in parallel or in random order. This page
covers all of it for the Bookshelf application that grows across this section: a public book list and detail page, a
review form for signed-in readers that emails the moderators after the review is committed, a delete action for
editors, a small async JSON API and a maintenance switch. pytest, factory libraries and real-browser tests are on the
sibling page pytest, Factories & Browser Tests; general Python
testing (pytest, fixtures, mocking) is on Unit Testing with pytest & Mocking.
In a hurry: what goes where
| Need | Rule of thumb | Section |
|---|---|---|
Code that never touches the database (URL resolving, forms without models, pure helpers) |
|
|
Almost everything else (models, views, forms with models) |
|
|
Shared rows for all tests of a class |
|
|
Real commit/rollback behaviour, |
|
|
A real HTTP server (browser tests, external clients) |
|
|
Exercise a URL end to end (middleware, URLconf, view, template) |
|
|
Call one view in isolation, without middleware |
|
|
Check redirects, templates, HTML, forms, query counts |
|
|
Code that runs after commit (emails, tasks) |
|
|
Change a setting for one test |
|
|
Assert on sent email |
|
|
Faster, stricter runs |
|
Where tests sit in the request cycle
A Django test never needs a running server for most of its work. The test client builds a request object and hands it to
the same handler the WSGI (or, for AsyncClient, the ASGI) server would call, so the request goes through the full
middleware stack, URL resolution, the view, the template engine and the response middleware — and the test gets back the
response together with extras a real browser never sees: the templates rendered, the context they received and the
resolved URL. A RequestFactory request skips all of that and is passed straight to one view function, so neither
middleware nor URL routing runs. A LiveServerTestCase is the only one that opens a socket: it starts a real server in a
thread so an external client (a browser driven by Playwright or Selenium, or urllib) can connect.
Every kind of test talks to the test database, never to the database named in DATABASES (see The test database),
and every test runs with DEBUG = False whatever the settings file says, so the output matches production.
Source: The test client, The request factory and Other test conditions
The running example
The tests on this page exercise one small catalog app. The models keep only the fields the tests need; the full
Bookshelf models are on Models & Fields.
from django.conf import settings
from django.db import models
from django.urls import reverse
class Author(models.Model):
name = models.CharField(max_length=200)
def __str__(self):
return self.name
class Book(models.Model):
title = models.CharField(max_length=300)
slug = models.SlugField(max_length=300, unique=True)
author = models.ForeignKey(Author, on_delete=models.PROTECT, related_name="books")
published = models.BooleanField(default=False)
class Meta:
ordering = ["title"]
def __str__(self):
return self.title
def get_absolute_url(self):
return reverse("catalog:book-detail", kwargs={"slug": self.slug})
class Review(models.Model):
book = models.ForeignKey(Book, on_delete=models.CASCADE, related_name="reviews")
user = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE)
rating = models.PositiveSmallIntegerField()
body = models.TextField()
created = models.DateTimeField(auto_now_add=True)
The review form lists its fields explicitly and validates the rating:
from django import forms
from .models import Review
class ReviewForm(forms.ModelForm):
class Meta:
model = Review
fields = ["rating", "body"]
def clean_rating(self):
rating = self.cleaned_data["rating"]
if not 1 <= rating <= 5:
raise forms.ValidationError("Rate the book from 1 to 5.")
return rating
The views cover the cases a test suite has to handle: a list that must not issue one query per book, a login-protected form that saves inside a transaction and emails the moderators only after the commit, a POST-only delete guarded by a permission, an async view and a view that depends on a setting.
from functools import partial
from django.conf import settings
from django.contrib.auth.decorators import login_required, permission_required
from django.core.mail import send_mail
from django.db import transaction
from django.http import HttpResponse, JsonResponse
from django.shortcuts import get_object_or_404, redirect, render
from django.views.decorators.http import require_POST
from django.views.generic import DetailView, ListView
from .forms import ReviewForm
from .models import Book
class BookListView(ListView):
queryset = Book.objects.filter(published=True).select_related("author")
template_name = "catalog/book_list.html"
class BookDetailView(DetailView):
queryset = Book.objects.filter(published=True).select_related("author")
template_name = "catalog/book_detail.html"
def notify_moderators(review_id, title):
send_mail(
subject=f"New review of {title}",
message=f"Review {review_id} is waiting for moderation.",
from_email=None,
recipient_list=settings.MODERATORS,
)
@login_required
def review_create(request, slug):
book = get_object_or_404(Book, slug=slug, published=True)
if request.method == "POST":
form = ReviewForm(request.POST)
if form.is_valid():
with transaction.atomic():
review = form.save(commit=False)
review.book = book
review.user = request.user
review.save()
transaction.on_commit(partial(notify_moderators, review.pk, book.title))
return redirect(book)
else:
form = ReviewForm()
return render(request, "catalog/review_form.html", {"form": form, "book": book})
@require_POST
@permission_required("catalog.delete_book", raise_exception=True)
def book_delete(request, slug):
book = get_object_or_404(Book, slug=slug)
book.delete()
return redirect("catalog:book-list")
async def api_book_list(request):
books = [
{"title": b.title, "author": b.author.name}
async for b in Book.objects.filter(published=True).select_related("author")
]
return JsonResponse({"books": books})
def status(request):
if settings.MAINTENANCE_MODE:
return HttpResponse("Down for maintenance", status=503)
return HttpResponse("OK")
from django.urls import path
from . import views
app_name = "catalog"
urlpatterns = [
path("", views.BookListView.as_view(), name="book-list"),
path("api/books/", views.api_book_list, name="api-book-list"),
path("status/", views.status, name="status"),
path("<slug:slug>/", views.BookDetailView.as_view(), name="book-detail"),
path("<slug:slug>/review/", views.review_create, name="review-create"),
path("<slug:slug>/delete/", views.book_delete, name="book-delete"),
]
The project URLconf mounts the app under books/ and includes django.contrib.auth.urls under accounts/ (so
reverse("login") is /accounts/login/); the settings add "catalog" to INSTALLED_APPS, read SECRET_KEY from the
environment, and define two project settings the views use, MODERATORS = ["moderators@bookshelf.example"] and
MAINTENANCE_MODE = False. The templates are a base template, catalog/book_list.html, catalog/book_detail.html,
catalog/review_form.html (a POST form with {% csrf_token %}) and registration/login.html.
Writing and running tests
Tests are ordinary unittest classes. The default runner, django.test.runner.DiscoverRunner, finds every module whose
name matches test*.py below the current directory, so an app’s tests.py works, and so does a tests/ package (it
needs an init.py) once one file grows too long. The suite on this page uses one module per concern:
catalog/
tests/
__init__.py
test_simple.py # SimpleTestCase: URLs and a form
test_views.py # TestCase + test client
test_client.py # client features and fixtures
test_async.py # async tests and AsyncClient
test_factory.py # RequestFactory
test_settings.py # override_settings / modify_settings
test_transactions.py # TransactionTestCase and LiveServerTestCase
test_db_guard.py # SimpleTestCase refusing queries
manage.py test with no argument runs everything; a test label narrows it to an app, a module, a class or one
method, using dotted paths (a directory path also works):
python manage.py test # whole project
python manage.py test catalog # one app
python manage.py test catalog.tests.test_views # one module
python manage.py test catalog.tests.test_views.BookDeleteTests
python manage.py test catalog.tests.test_views.BookDeleteTests.test_editor_can_delete
python manage.py test catalog -k review # names matching a pattern
The full run of the page’s suite:
$ python manage.py test catalog
Found 31 test(s).
Creating test database for alias 'default'...
System check identified no issues (0 silenced).
...............................
----------------------------------------------------------------------
Ran 31 tests in 1.671s
OK
Destroying test database for alias 'default'...
Before the first test the runner calls setup_test_environment() (in-memory email, ALLOWED_HOSTS extended with
testserver, DEBUG forced to False), creates the test databases, runs the system checks, runs the tests in a
defined order (The order tests run in), then destroys the databases. -v 2 lists every test by name and every migration applied to
the test database. A run that is killed half-way may leave the test database behind; the next run asks whether to
destroy it, and --noinput answers yes automatically, which is what CI wants.
Source: Writing tests, Running tests and django-admin test
The test case classes
Django adds four classes on top of unittest.TestCase, each one a subclass of the previous, each adding power and cost:
| Class | Database | Isolation between tests | Typical use |
|---|---|---|---|
|
Queries are refused (an |
Nothing to isolate. |
URLs, forms without models, template tags, helpers, settings-driven views. |
|
Allowed; real commits and rollbacks. |
All tables truncated (flushed) after each test. |
Code that depends on commit/rollback: |
|
Allowed; each test wrapped in |
The transaction is rolled back after each test. |
The default for anything that touches the database. |
|
As |
As |
A real server in a thread for browsers and other HTTP clients. |
TestCase actually inherits from TransactionTestCase, and LiveServerTestCase also inherits from
TransactionTestCase; django.contrib.staticfiles.testing.StaticLiveServerTestCase extends LiveServerTestCase to
serve static files the way runserver does. All of them give every test a fresh self.client and
self.async_client, the Django assertions, settings()/modify_settings() and an emptied mail.outbox.
Source: Provided test case classes
SimpleTestCase
Use it for anything that does not need rows. It is the cheapest class because the runner does not have to prepare any database for it, and it doubles as a guard: a query from code that "should not hit the database" fails the test.
from django.test import SimpleTestCase
from django.urls import resolve, reverse
from catalog import views
from catalog.forms import ReviewForm
class UrlTests(SimpleTestCase):
def test_book_list_url_resolves_to_list_view(self):
match = resolve("/books/")
self.assertEqual(match.func.view_class, views.BookListView)
self.assertEqual(match.view_name, "catalog:book-list")
def test_review_url_is_reversible(self):
self.assertEqual(
reverse("catalog:review-create", kwargs={"slug": "dune"}),
"/books/dune/review/",
)
def test_rating_outside_range_is_rejected(self):
form = ReviewForm(data={"rating": 9, "body": "Too good"})
self.assertFalse(form.is_valid())
self.assertEqual(form.errors["rating"], ["Rate the book from 1 to 5."])
A ModelForm can be validated in a SimpleTestCase as long as validation itself runs no query (no unique check, no
ModelChoiceField). A query is refused with a clear message:
from django.test import SimpleTestCase
from catalog.models import Book
class DatabaseGuardTests(SimpleTestCase):
def test_simple_test_case_blocks_queries(self):
with self.assertRaisesMessage(
AssertionError, "Database queries to 'default' are not allowed"
):
Book.objects.count()
To allow queries in a SimpleTestCase subclass set its databases attribute (for example databases = {"default"}),
but then the test is responsible for cleaning up — usually a sign it should be a TestCase. Since Django 5.1 database
connections opened inside threads are refused in SimpleTestCase too.
Source: SimpleTestCase and SimpleTestCase.databases
TestCase
TestCase wraps the whole class in one atomic() block and each test in a nested one; at the end of each test the inner
block is rolled back, so the next test sees the database exactly as it was after the class was set up. It also checks
deferrable constraints at the end of each test, so a violation that would only surface at commit still fails. Most
of the page’s tests are TestCase subclasses.
Because the test itself runs inside a transaction, some things cannot be observed: whether code runs inside a
transaction (select_for_update() always appears to work), the effects of a real COMMIT (on_commit callbacks never
run unless captured, see Testing on_commit callbacks inside TestCase), or what a second connection sees. A nested atomic(durable=True) raises a
RuntimeError inside TestCase since Django 4.1, as it would outside tests. Those cases need
TransactionTestCase.
Source: TestCase
setUpTestData: create rows once per class
setUp() runs before every test; setUpTestData() is a class method that runs once, inside the class-level atomic
block, and its rows are rolled back only when the class finishes. For a class with ten tests that each need an author,
two books and a user, that is one round of inserts and password hashing instead of ten.
from django.contrib.auth import get_user_model
from django.contrib.auth.models import Permission
from django.core import mail
from django.test import TestCase
from django.urls import reverse
from catalog.models import Author, Book, Review
class BookViewTests(TestCase):
@classmethod
def setUpTestData(cls):
cls.author = Author.objects.create(name="Ursula K. Le Guin")
cls.book = Book.objects.create(
title="The Dispossessed", slug="the-dispossessed",
author=cls.author, published=True,
)
cls.draft = Book.objects.create(
title="Untitled draft", slug="untitled-draft", author=cls.author,
)
cls.reader = get_user_model().objects.create_user(
"reader", password="not-a-real-password-1"
)
Two rules keep it safe. Since Django 3.2 every object assigned to a class attribute in setUpTestData() is deep-copied
for each test, so a test that changes self.book.title (even without saving) does not leak the change to the next test;
the objects must therefore support copy.deepcopy() (model instances do; an open file or a lock does not). And the
database changes are only isolated because they happen inside the class transaction — on a backend without
transactions (MySQL with MyISAM) setUpTestData() runs before each test and the speed-up disappears.
setUp() is still the right place for things that are not database rows and are cheap: a RequestFactory, a
temporary directory, a mock. setUpClass() exists too, but when overriding it always call super().setUpClass()
first, because Django’s own class set-up (the class transaction, fixture loading) lives there.
TransactionTestCase
TransactionTestCase lets the code under test commit and roll back for real, and cleans up afterwards by truncating
every table. That makes it the only class where an on_commit callback fires on its own and a rollback really discards
it:
from functools import partial
from urllib.request import urlopen
from django.core import mail
from django.db import transaction
from django.test import LiveServerTestCase, TransactionTestCase, tag
from catalog.models import Author, Book
from catalog.views import notify_moderators
class OnCommitTests(TransactionTestCase):
available_apps = ["catalog"] # flush only these tables after each test
def test_callback_runs_after_real_commit(self):
with transaction.atomic():
transaction.on_commit(partial(notify_moderators, 1, "Kindred"))
self.assertEqual(mail.outbox, []) # still inside the transaction
self.assertEqual(len(mail.outbox), 1)
def test_callback_discarded_on_rollback(self):
try:
with transaction.atomic():
transaction.on_commit(partial(notify_moderators, 1, "Kindred"))
raise RuntimeError("payment failed")
except RuntimeError:
pass
self.assertEqual(mail.outbox, [])
The price is speed: truncating all tables after each test is much slower than a rollback, and the cost grows with the number of models. Three attributes tune it:
-
available_apps— a list of app labels; the flush (and thepost_migratehandlers that recreate content types and permissions) is restricted to those apps. The docs mark it as a private API (it exists to speed up Django’s own suite and may change without deprecation); rows created in other apps leak into later tests (sessions included, since the default session engine stores them in the database), and either allTransactionTestCaseclasses of a suite should declare it or none. -
reset_sequences = True— resets primary-key sequences before each test so ids start at 1 (slower; better to never hardcode ids). Since Django 3.1 sequences are no longer reset as a side effect of the flush on MySQL. -
serialized_rollback = True— reloads data created by migrations (data migrations) after each flush, which is otherwise lost; it slows the class roughly three times.TEST_NON_SERIALIZED_APPSexcludes apps from the serialization step.
LiveServerTestCase
LiveServerTestCase starts a real HTTP server in a separate thread on a free port of localhost when the class is set
up, and exposes its address as self.live_server_url. Anything that can speak HTTP can then test the application — here the standard library’s urllib, in practice usually a browser driven by Playwright or Selenium:
@tag("slow", "live")
class LiveServerSmokeTests(LiveServerTestCase):
def test_book_list_over_real_http(self):
author = Author.objects.create(name="Ted Chiang")
Book.objects.create(title="Exhalation", slug="exhalation",
author=author, published=True)
with urlopen(f"{self.live_server_url}/books/") as response:
body = response.read().decode()
self.assertEqual(response.status, 200)
self.assertIn("Exhalation", body)
The rows created by the test are visible to the server thread because the class is a TransactionTestCase: the data is
really committed (and flushed after the test). With SQLite the test database is an in-memory database with shared
cache, so both threads see the same data. The plain class does not serve static files; use
StaticLiveServerTestCase (from django.contrib.staticfiles.testing) when pages need CSS and JavaScript, which is the
starting point for the browser tests on pytest, Factories & Browser Tests
and the general guide E2E Testing in Real Browsers.
Source: LiveServerTestCase
The order tests run in
To guarantee that every TestCase starts from a clean database, the runner reorders the suite: all TestCase
subclasses first, then the other Django test classes (SimpleTestCase, TransactionTestCase and their subclasses) in no
guaranteed order, then any plain unittest.TestCase and doctests. Tests that fail to load (an import error, a syntax
error) are reported before everything else. Inside those groups --shuffle and --reverse change the order to expose
tests that depend on each other (Runner options).
The test client
self.client is an instance of django.test.Client, created fresh for each test. It behaves like a minimal browser:
it keeps cookies (and therefore the session) between requests, can log in, and has one method per HTTP verb — get(), post(), put(), patch(), delete(), head(), options() and trace(). Every method accepts:
-
data— a dict encoded as the query string forget()/head(), asmultipart/form-dataforpost()by default (open files in it are uploaded), or serialized withjson.dumps()whencontent_type="application/json"and the value is a dict, list or tuple (thejson_encoderclient argument,DjangoJSONEncoderby default, controls that); -
follow=True— follow redirects; the final response gets aredirect_chainlist of(url, status_code)pairs; -
secure=True— emulate an HTTPS request; -
headers={…}(Django 4.2+) — request headers by their normal names; before 4.2 they had to be passed asHTTP_ACCEPT_LANGUAGE="fr"-style keyword arguments, which still works; -
query_params={…}(Django 5.1+) — a query string for any method, includingpost().
The client constructor takes enforce_csrf_checks (default False: the client skips CSRF validation unless asked),
raise_request_exception (default True: an exception in a view is re-raised in the test instead of becoming a 500;
with False the response is a 500 that carries exc_info), json_encoder, and default headers/query_params.
from django.contrib.auth import get_user_model
from django.test import TestCase
from django.urls import reverse
from catalog.models import Book
class ClientFeatureTests(TestCase):
fixtures = ["sample_books"]
@classmethod
def setUpTestData(cls):
cls.reader = get_user_model().objects.create_user(
"reader", password="not-a-real-password-1"
)
def test_fixture_loaded(self):
self.assertTrue(Book.objects.filter(slug="frankenstein").exists())
def test_follow_records_the_redirect_chain(self):
url = reverse("catalog:review-create", kwargs={"slug": "frankenstein"})
response = self.client.get(url, follow=True)
self.assertEqual(response.redirect_chain, [(f"/accounts/login/?next={url}", 302)])
self.assertTemplateUsed(response, "registration/login.html")
def test_login_with_credentials(self):
self.assertTrue(
self.client.login(username="reader", password="not-a-real-password-1")
)
self.assertIn("_auth_user_id", self.client.session)
def test_query_params_and_headers(self):
response = self.client.get(
reverse("catalog:book-list"),
query_params={"page": "1"},
headers={"accept-language": "en"},
)
self.assertEqual(response.wsgi_request.GET["page"], "1")
self.assertEqual(response.wsgi_request.headers["Accept-Language"], "en")
self.assertEqual(response.resolver_match.url_name, "book-list")
def test_template_used_as_context_manager(self):
with self.assertTemplateUsed("catalog/book_detail.html"):
self.client.get("/books/frankenstein/")
def test_csrf_checks_can_be_enforced(self):
from django.test import Client
csrf_client = Client(enforce_csrf_checks=True)
csrf_client.force_login(self.reader)
url = reverse("catalog:review-create", kwargs={"slug": "frankenstein"})
response = csrf_client.post(url, {"rating": 4, "body": "Alive!"})
self.assertEqual(response.status_code, 403)
The fixture loaded by fixtures = ["sample_books"] is catalog/fixtures/sample_books.json (see Test data: fixtures or factories).
The last test is worth keeping in a real suite: because the default client skips CSRF checks, a form that forgot
{% csrf_token %} still passes every ordinary test; a client with enforce_csrf_checks=True proves the protection is on.
Source: Making requests and Testing and CSRF protection
Logging in
client.login(**credentials) runs the real authentication backends (here username and password) and returns True or
False; it hashes the password, so it is slow with the default hasher. client.force_login(user, backend=None) skips
authentication and attaches the user to the session directly — the right choice whenever the test is about the view,
not about logging in. client.logout() clears the session. Since Django 5.0 each has an async counterpart for async
tests: alogin(), aforce_login(), alogout(), plus asession() for the session.
Source: Client.login() and Client.force_login()
What the response carries
The response is the view’s HttpResponse with extra attributes set by the test client:
| Attribute | Content |
|---|---|
|
As in a real response; |
|
The body parsed as JSON (only for a JSON |
|
The template context, looked up by key ( |
|
The list of |
|
The |
|
The request data, and the actual |
|
The client that made the request. |
|
With |
|
With |
client.cookies is a SimpleCookie with the cookies the "browser" holds; client.session returns the current session.
To modify the session, take it into a variable, change it and call save() on it — self.client.session["key"] = value
alone is lost, because each access returns a new session object.
Source: Testing responses and Persistent state
Async tests and the AsyncClient
The synchronous client can already call async views: it runs them in their own event loop, so the async API view could
be tested with self.client.get(…). Fully asynchronous tests are needed when the test itself awaits something — the
async ORM, an async helper, the async client. Declaring the test method async def is enough; Django detects it and runs
it in an event loop. Inside, use self.async_client (an AsyncClient), whose methods must be awaited:
from django.test import TestCase
from catalog.models import Author, Book
class AsyncApiTests(TestCase):
@classmethod
def setUpTestData(cls):
author = Author.objects.create(name="N. K. Jemisin")
Book.objects.create(title="The Fifth Season", slug="the-fifth-season",
author=author, published=True)
async def test_api_lists_published_books(self):
response = await self.async_client.get(
"/books/api/books/", headers={"accept": "application/json"}
)
self.assertEqual(response.status_code, 200)
self.assertJSONEqual(
response.content,
{"books": [{"title": "The Fifth Season", "author": "N. K. Jemisin"}]},
)
async def test_async_orm_inside_async_test(self):
self.assertEqual(await Book.objects.filter(published=True).acount(), 1)
setUpTestData() stays synchronous, and the rows it creates are visible to the async test and to the async view (both
tests above pass inside the ordinary TestCase transaction); the async ORM methods (acount(), aget(), async for)
are the ones to use inside async def tests, since a synchronous query there raises SynchronousOnlyOperation.
Differences from the sync client:
-
Requests go through Django’s ASGI handler, and the view receives an
ASGIRequest. Sync views work through it too. -
Extra keyword arguments go into the ASGI scope, so headers passed that way have no
HTTP_prefix (ACCEPT="…"); theheaders=argument works the same in both clients. -
follow=Trueis supported since Django 5.0, as arealogin(),aforce_login(),alogout()andasession(). -
Decorators on async tests must be async-aware. Django’s own (
override_settings,tag, …) are; a third-party decorator such asmock.patchmay silently wrap the coroutine creation instead of the test. The documented workaround is to put@async_to_sync(fromasgiref.sync) directly on theasync def, below the other decorators.
Async views and the async ORM are explained on Async Support.
Source: Testing asynchronous code and AsyncClient
RequestFactory: testing a view in isolation
RequestFactory has the same request-building methods as the client (get(), post(), … with data, headers,
query_params, secure) but only builds the request; the test passes it to the view itself. No middleware runs, so
there is no session, no request.user and no messages unless the test adds them, and no URL is resolved, so URL keyword
arguments are passed by hand. That makes it the tool for unit-testing view logic, a mixin, a decorator or a template
context processor without the rest of the stack; AsyncRequestFactory builds ASGI requests for async views.
from django.contrib.auth.models import AnonymousUser
from django.test import AsyncRequestFactory, RequestFactory, TestCase
from catalog import views
from catalog.models import Author, Book
class RequestFactoryTests(TestCase):
@classmethod
def setUpTestData(cls):
author = Author.objects.create(name="Iain M. Banks")
Book.objects.create(title="Excession", slug="excession",
author=author, published=True)
def setUp(self):
self.factory = RequestFactory()
def test_anonymous_user_is_sent_to_login(self):
request = self.factory.get("/books/excession/review/")
request.user = AnonymousUser() # no middleware runs, so set it by hand
response = views.review_create(request, slug="excession")
self.assertEqual(response.status_code, 302)
self.assertTrue(response.url.startswith("/accounts/login/"))
def test_class_based_view_context(self):
request = self.factory.get("/books/")
view = views.BookListView()
view.setup(request)
view.object_list = view.get_queryset()
context = view.get_context_data()
self.assertEqual([b.title for b in context["book_list"]], ["Excession"])
async def test_async_view_with_async_factory(self):
request = AsyncRequestFactory().get("/books/api/books/")
response = await views.api_book_list(request)
self.assertEqual(response.status_code, 200)
The second test shows the documented way to test a class-based view method directly: instantiate the class, call
setup(request, *args, **kwargs) (which as_view() would normally call), then the method under test. Responses from a
factory-called view are plain responses: TemplateResponse objects are not rendered yet and there is no
response.context or response.templates; prefer the test client when those matter. For a POST view that renders a
form, remember the factory request carries no CSRF cookie — harmless here, since CSRF is enforced by middleware that
does not run.
Assertions
On top of unittest’s `assertEqual(), assertTrue(), assertRaises() and friends, Django’s test case classes add
assertions that understand responses, HTML, forms and SQL. Since Django 5.2 their own stack frames are hidden from
failure tracebacks, so a failure points at the test line and --pdb stops in the test method.
| Assertion | Checks |
|---|---|
|
The status code matches and |
|
The status code matches and |
|
The response redirects to |
|
A template was (or was not) rendered; also usable as a context manager around the request. |
|
A bound form (or formset) has exactly these errors on |
|
HTML compared semantically: attribute order and insignificant whitespace are ignored. |
|
A JSON string (or bytes) parses to the expected Python data. |
|
XML compared semantically; URLs compared with query parameters in any order. |
|
The exception (or warning) is raised and its message contains |
|
A form field cleans each valid input to the expected value and rejects each invalid input with the expected errors. |
|
A queryset (or any iterable) equals |
|
Exactly |
The view tests of the running example use most of them:
def test_list_shows_published_books_only(self):
response = self.client.get(reverse("catalog:book-list"))
self.assertEqual(response.status_code, 200)
self.assertTemplateUsed(response, "catalog/book_list.html")
self.assertContains(response, "The Dispossessed")
self.assertNotContains(response, "Untitled draft")
self.assertQuerySetEqual(response.context["book_list"], [self.book])
def test_list_query_count_does_not_grow_with_books(self):
for i in range(5):
Book.objects.create(
title=f"Book {i}", slug=f"book-{i}", author=self.author, published=True
)
with self.assertNumQueries(1):
self.client.get(reverse("catalog:book-list"))
def test_draft_detail_is_404(self):
response = self.client.get(self.draft.get_absolute_url())
self.assertEqual(response.status_code, 404)
def test_review_requires_login(self):
url = reverse("catalog:review-create", kwargs={"slug": self.book.slug})
response = self.client.get(url)
self.assertRedirects(response, f"{reverse('login')}?next={url}",
fetch_redirect_response=False)
def test_invalid_review_redisplays_form_with_error(self):
self.client.force_login(self.reader)
url = reverse("catalog:review-create", kwargs={"slug": self.book.slug})
response = self.client.post(url, {"rating": 0, "body": "Meh"})
self.assertEqual(response.status_code, 200)
self.assertFormError(response.context["form"], "rating",
"Rate the book from 1 to 5.")
self.assertFalse(Review.objects.exists())
self.assertEqual(mail.outbox, [])
Removing select_related("author") from BookListView makes the query-count test fail with the queries listed (output
of the scratch run, long lines shortened):
AssertionError: 7 != 1 : 7 queries executed, 1 expected
Captured queries were:
1. SELECT "catalog_book"."id", "catalog_book"."title", ... FROM "catalog_book" ...
2. SELECT "catalog_author"."id", "catalog_author"."name" FROM "catalog_author" WHERE "catalog_author"."id" = 1 LIMIT 21
3. SELECT "catalog_author"."id", "catalog_author"."name" FROM "catalog_author" WHERE "catalog_author"."id" = 1 LIMIT 21
...
7. SELECT "catalog_author"."id", "catalog_author"."name" FROM "catalog_author" WHERE "catalog_author"."id" = 1 LIMIT 21
Notes on using them well:
-
assertNumQueries(1)on the list view is a regression test for the N+1 problem: removeselect_related("author")and the count becomes 1 + number of books, and the test fails with the list of queries that ran. The count includes every query in the block — session and user loading for a logged-in client, savepoints, the queries ofsetUp()if it is inside the block — so measure the request, not the set-up. The underlying causes and fixes are on Query Optimization & Fetch Modes. -
assertRedirectsfetches the target by default;fetch_redirect_response=Falseis needed when the target cannot be served in the test (an external URL, or a page whose template the test project lacks). -
assertContainschecks the status code too, soassertContains(response, "…", status_code=503)asserts both at once; it decodes the body with the response charset. Since Django 6.1 it can be called several times on the sameStreamingHttpResponse(previously the first call consumed the stream). -
Since Django 4.1
assertFormError()takes the form itself (response.context["form"]); the older signature that took the response and a context name was deprecated then and removed in Django 5.0. -
Since Django 4.2 the queryset assertion is spelled
assertQuerySetEqual; the oldassertQuerysetEqualwas removed in Django 5.1. -
assertRaisesMessagedoes a substring match; to check an exact message useassertRaisesand comparestr(cm.exception).
Source: Assertions
Testing on_commit callbacks inside TestCase
The review view saves inside transaction.atomic() and registers notify_moderators with transaction.on_commit() so
the email only leaves if the review is really stored. Inside a TestCase the outer transaction never commits, so the
callback would never run and a naive "an email was sent" assertion would fail. captureOnCommitCallbacks() (Django 3.2+)
collects the callbacks registered in its block; with execute=True it runs them when the block exits without an
exception, emulating the commit:
def test_valid_review_is_saved_and_moderators_emailed(self):
self.client.force_login(self.reader)
url = reverse("catalog:review-create", kwargs={"slug": self.book.slug})
with self.captureOnCommitCallbacks(execute=True) as callbacks:
response = self.client.post(url, {"rating": 5, "body": "A classic."})
self.assertRedirects(response, self.book.get_absolute_url())
review = Review.objects.get()
self.assertEqual((review.user, review.book, review.rating),
(self.reader, self.book, 5))
self.assertEqual(len(callbacks), 1)
self.assertEqual(len(mail.outbox), 1)
self.assertEqual(mail.outbox[0].subject, "New review of The Dispossessed")
self.assertEqual(mail.outbox[0].to, ["moderators@bookshelf.example"])
self.assertEqual(mail.outbox[0].sent_using, "default") # Django 6.1+
Without execute=True the list can be inspected (and the callbacks called by hand) to assert that something would
run, without running it. Since Django 4.0 callbacks registered while executing other callbacks are captured too. The
using argument selects another database alias. The same pattern tests enqueueing a background task after commit, as
shown on Tasks Framework; the real commit/rollback behaviour itself needs
TransactionTestCase.
Email in tests: mail.outbox
The runner never sends real email. Before the tests start it replaces the backend of every configured mailer — in
Django 6.1 every alias in the new MAILERS setting; on 5.2 the single EMAIL_BACKEND — with the in-memory locmem
backend, which appends each sent message to django.core.mail.outbox. Access it as mail.outbox after
from django.core import mail: the attribute only exists while the locmem backend is active, and Django replaces it
with a new empty list before every test, so a name imported with from django.core.mail import outbox would keep
pointing at a stale list. Each entry is an EmailMessage with subject, body, from_email, to,
cc, bcc, attachments and, new in Django 6.1, sent_using (the mailer alias that sent it). Every Django test case
empties the outbox before each test; to empty it in the middle of a test assign mail.outbox = [].
The review test above checks the subject and the recipients; the invalid-review test asserts mail.outbox == [], which
is the half that is easy to forget: an email sent for an invalid submission is a bug too. Configuring mailers and email
templates is on Email.
Source: Email services and Emptying the test outbox
Changing settings in a test
Never assign to django.conf.settings in a test: Django will not restore the value and every later test sees it. Three
tools change settings for a limited scope and restore them afterwards, sending the setting_changed signal both ways:
-
@override_settings(NAME=value, …)on a test method or a test class (or as a context manager); -
self.settings(NAME=value), the context-manager form available on every test case; -
@modify_settings(NAME={"append": …, "prepend": …, "remove": …})(orself.modify_settings(…)) for list settings such asMIDDLEWAREandINSTALLED_APPS, where replacing the whole list would be brittle. Each value is a string or a list of strings; appending or prepending a value already present does nothing, and so does removing one that is absent.
from django.conf import settings
from django.test import SimpleTestCase, modify_settings, override_settings
class StatusTests(SimpleTestCase):
def test_ok_by_default(self):
self.assertContains(self.client.get("/books/status/"), "OK")
@override_settings(MAINTENANCE_MODE=True)
def test_maintenance_mode_returns_503(self):
response = self.client.get("/books/status/")
self.assertContains(response, "Down for maintenance", status_code=503)
def test_context_manager_form(self):
with self.settings(MAINTENANCE_MODE=True):
self.assertEqual(self.client.get("/books/status/").status_code, 503)
self.assertEqual(self.client.get("/books/status/").status_code, 200)
@modify_settings(MIDDLEWARE={
"remove": "django.middleware.clickjacking.XFrameOptionsMiddleware",
})
class WithoutClickjackingTests(SimpleTestCase):
def test_header_absent_when_middleware_removed(self):
response = self.client.get("/books/status/")
self.assertNotIn("X-Frame-Options", response.headers)
self.assertNotIn(
"django.middleware.clickjacking.XFrameOptionsMiddleware",
settings.MIDDLEWARE,
)
Pitfalls:
-
The override only works for code that reads
settings.NAMEat call time. A module-level alias such asMAINTENANCE = settings.MAINTENANCE_MODEis evaluated once at import and never sees the override — read settings inside functions. -
Some settings are only read when Django starts. Overriding
DATABASESis not recommended; overridingCACHESis possible but tricky with features that hold on to a cache (cached sessions need their backend reinitialised). Django resets its own state for the settings it knows about (time zone,TEMPLATES,FORM_RENDERER,STORAGES,LANGUAGE_CODE, …); for app-level caches derived from a setting, connect a receiver todjango.test.signals.setting_changed. -
Applied to a class, the decorators modify that class in place (they do not return a copy), and
modify_settingsis always applied afteroverride_settings. They work on async test methods too. -
@override_settings()followed bydel settings.NAMEinside the test simulates a missing setting.
Source: Overriding settings, override_settings() and modify_settings()
The test database
Tests never touch the database configured in DATABASES. For every alias the tests use, the runner creates a separate,
empty database, applies all migrations to it, and destroys it at the end — whether the tests passed or failed:
-
The name is
test_followed by theNAMEof the alias (test_bookshelffor a PostgreSQL database calledbookshelf). With SQLite the default is an in-memory database (shared-cache, so a live-server thread sees the same data), which is why the runs on this page printCreating test database for alias 'default'…without a file. -
Every other connection setting (
ENGINE,USER,HOST, …) is reused, so the database user needs permission to create databases; on PostgreSQL it also needs read access to the built-inpostgresdatabase. -
The
TESTdictionary inside an alias tunes the test database:NAME(another name, or a file path to make SQLite use a file),MIGRATE(Falsecreates tables straight from the models instead of running migrations — faster on a long migration history, but it no longer tests the migrations and data migrations are skipped),CHARSETandCOLLATION(MySQL),MIRROR(treat a replica alias as a mirror of another alias during tests),DEPENDENCIES(creation order for multiple databases),TEMPLATE(PostgreSQL template database), plus Oracle-specific keys. -
Data created by data migrations is present in
TestCasetests, but aTransactionTestCaseflush removes it unlessserialized_rollback = True(see TransactionTestCase). -
Code that queries the database at import time (module level,
AppConfig.ready()) runs before the test database exists and may read production data — one more reason never to query at import time.
DATABASES = {
"default": {
"ENGINE": "django.db.backends.postgresql",
"NAME": "bookshelf",
"USER": os.environ["POSTGRES_USER"],
"PASSWORD": os.environ["POSTGRES_PASSWORD"],
"HOST": os.environ.get("POSTGRES_HOST", "localhost"),
"TEST": {
"NAME": "bookshelf_test",
},
}
}
(This block was checked with manage.py check and diffsettings, but the scratch run had no PostgreSQL server; the
tests on this page ran on SQLite.) Run tests against the same database engine as production: SQLite accepts things
PostgreSQL rejects (and the other way round), so a suite that only ever runs on SQLite can hide real failures. Database
backends are compared on PostgreSQL & Database Backends.
With several databases a test class must declare which ones it uses, with the databases attribute
(databases = {"default", "archive"}, or "all"); queries to undeclared aliases fail. Multiple databases are on
Transactions, Raw SQL & Multiple Databases.
Runner options
manage.py test accepts its own options (--failfast, --testrunner, --noinput) and passes the rest to the test
runner. The default DiscoverRunner understands:
| Option | Effect |
|---|---|
|
Runs test case classes in N processes ( |
|
Randomises the order inside the ordering groups (Django 4.0+); prints the seed so a failing order can be replayed. |
|
Runs the test classes in reverse order (combinable with |
|
Runs only (or skips) tests marked with |
|
Runs test methods and classes whose names match, like |
|
Keeps the test database between runs, applying only new migrations; skips creation and destruction. |
|
Prints the SQL of failing tests (of all tests at |
|
Sets |
|
Opens the debugger (ipdb if installed) on each error or failure; use without |
|
Hides the output of passing tests (works with |
|
Prints database set-up, teardown and total run times (Django 3.2+). |
|
Lists the N slowest tests (Django 5.0+, needs Python 3.12+). |
|
Disables the |
Output from the scratch project, trimmed to the interesting lines:
$ python manage.py test catalog --parallel 4
Creating test database for alias 'default'...
Cloning test database for alias 'default'...
Cloning test database for alias 'default'...
Cloning test database for alias 'default'...
Cloning test database for alias 'default'...
Found 31 test(s).
...............................
Ran 31 tests in 1.306s
OK
$ python manage.py test catalog --shuffle
Using shuffle seed: 7448720602 (generated)
...
Ran 31 tests in 1.156s
OK
Used shuffle seed: 7448720602 (generated)
$ python manage.py test catalog --shuffle 7448720602 # replay that exact order
$ python manage.py test catalog --tag live
Found 1 test(s).
...
$ python manage.py test catalog --exclude-tag slow
Found 30 test(s).
$ python manage.py test catalog --keepdb
Using existing test database for alias 'default'...
...
Preserving test database for alias 'default'...
--parallel distributes whole test case classes, so classes must not share external resources (a directory, a cache
server, a port) — give each its own temporary directory, or mark classes that cannot run concurrently with
django.test.testcases.SerializeMixin and a shared lockfile. Tracebacks from worker processes need the tblib
package to be displayed; without it a failure may show no traceback at all, so rerun the failing test with
--parallel 1. Parallel runs work with the spawn start method on macOS and Windows since Django 4.1 and with
forkserver since 6.0. On SQLite in memory, --keepdb has nothing to keep (the database lives only in the process); it
pays off on PostgreSQL or MySQL, where creating the database and running all migrations dominates a short run.
--debug-sql on a deliberately failing test prints the query after the traceback:
$ python manage.py test catalog.tests.test_fail --debug-sql
FAIL: test_counts (catalog.tests.test_fail.FailingTests.test_counts)
...
AssertionError: 0 != 1
----------------------------------------------------------------------
(0.000) SELECT COUNT(*) AS "__count"
FROM "catalog_book"
WHERE "catalog_book"."published" = 1; args=(True,); alias=default
Source: Test runner options and test --parallel
Tagging and skipping
@tag("slow", "live") from django.test marks a test method or a whole class (the live-server class above); tags on a
class apply to all its methods, and a method can add its own. A common split is a fast default run with
--exclude-tag slow locally and the full suite in CI.
Skipping uses the standard unittest.skip* decorators, plus two Django ones that look at the database backend:
@skipUnlessDBFeature("supports_json_field_contains") and @skipIfDBFeature(…), which read the backend’s
connection.features flags — useful in a reusable app tested against several databases.
Source: Tagging tests and Skipping tests
Keeping the suite fast
A test suite that takes minutes stops being run. In rough order of payoff:
-
Use
TestCaserather thanTransactionTestCase, andsetUpTestData()rather thansetUp()for rows. -
Use
force_login()rather thanlogin(), and a fast password hasher in a test settings module — the default PBKDF2 hasher is deliberately slow, and everycreate_user()andlogin()pays for it. -
Run with
--parallelonce the suite is isolated (prove it with--shuffle). -
Use
--keepdblocally on PostgreSQL/MySQL. -
Keep media files in memory with
InMemoryStorageso tests that upload files never touch the disk. -
Find the slow tests with
--durations 10and the set-up cost with--timing.
from .settings import * # noqa: F403
PASSWORD_HASHERS = ["django.contrib.auth.hashers.MD5PasswordHasher"]
STORAGES = {
"default": {"BACKEND": "django.core.files.storage.InMemoryStorage"},
"staticfiles": {"BACKEND": "django.contrib.staticfiles.storage.StaticFilesStorage"},
}
$ python manage.py test catalog --settings=config.settings_test --timing
...
Ran 31 tests in 0.617s
OK
Total database setup took 0.173s
Creating 'default' took 0.173s
Total database teardown took 0.000s
Total run took 0.963s
MD5 is only acceptable here because the test users exist for seconds in a throw-away database; never use it outside a test settings module. If fixtures contain password hashes made with another algorithm, keep that hasher in the list.
Test data: fixtures or factories
A fixture is a serialized file (JSON, XML, or YAML with PyYAML) loaded by loaddata. A test class lists fixtures by
name in its fixtures attribute; Django looks for them in each app’s fixtures/ directory and FIXTURE_DIRS, and loads
them once per class in a TestCase (before each test in a TransactionTestCase):
[
{"model": "catalog.author", "pk": 1, "fields": {"name": "Mary Shelley"}},
{"model": "catalog.book", "pk": 1,
"fields": {"title": "Frankenstein", "slug": "frankenstein", "author": 1, "published": true}}
]
ClientFeatureTests above loads it with fixtures = ["sample_books"]. Fixtures are convenient for a small, stable
reference data set, but they age badly: every new required field means editing every file, hardcoded primary keys
collide, and a test’s important values sit in a different file from the assertion. Creating objects in code — in
setUpTestData() directly, or with a factory that fills in sensible defaults and lets each test override only what
it cares about — keeps the data next to the test and survives model changes. That is why the tests on this page create
their rows in code. factory_boy, model-bakery and pytest-django are covered on
pytest, Factories & Browser Tests; the fixture formats,
dumpdata/loaddata and natural keys on Fixtures, Serialization & Initial Data.
Source: Fixture loading and Fixtures
Testing state changes and permissions
A test of a state-changing view has to check three things: the status code (or redirect), the state in the database afterwards, and that the wrong user or the wrong method cannot do it. The delete tests of the running example do all three:
class BookDeleteTests(TestCase):
@classmethod
def setUpTestData(cls):
author = Author.objects.create(name="Octavia E. Butler")
cls.book = Book.objects.create(
title="Kindred", slug="kindred", author=author, published=True
)
User = get_user_model()
cls.reader = User.objects.create_user("reader", password="not-a-real-password-1")
cls.editor = User.objects.create_user("editor", password="not-a-real-password-2")
cls.editor.user_permissions.add(Permission.objects.get(codename="delete_book"))
cls.url = reverse("catalog:book-delete", kwargs={"slug": "kindred"})
def test_editor_can_delete(self):
self.client.force_login(self.editor)
response = self.client.post(self.url)
self.assertRedirects(response, reverse("catalog:book-list"))
self.assertFalse(Book.objects.filter(pk=self.book.pk).exists())
def test_reader_cannot_delete(self):
self.client.force_login(self.reader)
response = self.client.post(self.url)
self.assertEqual(response.status_code, 403)
self.assertTrue(Book.objects.filter(pk=self.book.pk).exists())
def test_get_does_not_delete(self):
self.client.force_login(self.editor)
response = self.client.get(self.url)
self.assertEqual(response.status_code, 405)
self.assertTrue(Book.objects.filter(pk=self.book.pk).exists())
The passwords are throw-away literals that exist only in the test database; force_login() means they are never even
checked. Permissions are cached on the user object after the first check, so when a test grants a permission and then
checks it on the same instance, reload the user first (User.objects.get(pk=…)); here the permission is granted in
setUpTestData() and each request loads the user afresh from the session. The broader patterns — object-level checks,
PermissionRequiredMixin, UserPassesTestMixin — are on
Permissions & Authorization.
Source: Client.force_login() and Permission caching
The books' faulty tests, corrected
Of the four books this section draws on, Melé’s Django 2 by Example builds four projects with no tests at all, and the
other three teach testing with the tools of Django 1.11 (Two Scoops of Django 1.11) and 2.x (Marani’s
Practical Django 2 and Channels 2, Vincent). Vincent’s Django for Beginners (Django 2.1) is the one that
introduces SimpleTestCase and TestCase most directly, and several of the blog tests in its chapter 6 pass for the
wrong reason. The mistakes are general enough to recognise in any codebase; they are described here (not reproduced)
and shown corrected with the Bookshelf views.
A POST test that asserts 200. The book’s create-view test posts form data, checks for status 200 and checks that the
submitted title and body appear in the page. A successful create view redirects (302) to the new object; a 200 after a
POST means the form was re-displayed — in the book because the posted author value is not valid form input — and the
"contains" checks only find the user’s own input echoed back in the form fields. The test therefore passes precisely
when the view fails to save. Its sibling update test has the opposite gap: it asserts the 302 but never reloads the
row to check that the title changed. The scratch project shows the same trap
with the review form: posting an out-of-range rating returns 200 and saves nothing. The fix is to assert the redirect
and the new row, as test_valid_review_is_saved_and_moderators_emailed does with assertRedirects(…) followed by
Review.objects.get(); and when testing the invalid case on purpose, assert the form error and that nothing was saved,
as test_invalid_review_redisplays_form_with_error does.
args='1' in reverse(). The book reverses a detail URL with a string for args. args is a sequence, and a
string is a sequence of characters, so "1" happens to become ["1"] and the test passes — until the primary key has
two digits: "12" becomes two arguments and reverse() raises NoReverseMatch. In the scratch project
reverse("catalog:book-detail", args="t") returns /books/t/ while args="dune" raises NoReverseMatch. Pass a list
or tuple (args=[book.pk]), or better keyword arguments (kwargs={"slug": book.slug}), or call the model’s
get_absolute_url(), which also tests that method.
A delete test that never deletes. The book’s delete test sends a GET to the delete URL, checks for status 200 and
describes that as confirming the post was deleted. Nothing was deleted: a GET to a DeleteView only renders the confirmation
form. A delete test must POST and then assert the row is gone (test_editor_can_delete), and the safety properties
deserve their own tests — another user cannot delete (test_reader_cannot_delete) and a GET does not delete
(test_get_does_not_delete).
The same chapter’s views also use fields = 'all' on model forms, which this section never does: the review form
lists fields = ["rating", "body"], and book and user are set by the view, so a reader cannot post a review as
somebody else. Two more habits from the books are outdated rather than wrong: logging in with client.login() in every
test (use force_login()) and creating the same rows in setUp() for each test (use setUpTestData(), which did not
exist before Django 1.8 and was not isolated per test until 3.2).
Source: reverse(), assertRedirects() and DeleteView
Pitfalls
-
Asserting only the status code. A 200 after a POST usually means an invalid form; a 302 says nothing about what was saved. Assert the database state and the side effects (
mail.outbox, callbacks) as well. -
Order-dependent tests. A test that passes alone but fails in the suite (or the reverse) depends on state another test left behind — a module-level variable, a cache, a file, a class attribute mutated in a test. Run
--shufflein CI and replay a failing seed locally. -
Caches are not reset between tests. Unlike the database, there is no test cache: values set by one test are seen by the next, and running tests against a production settings file can write into a live cache. Use a
locmemcache in the test settings and clear it insetUp()where it matters. -
Expecting
on_committo fire inTestCase. It never does on its own; usecaptureOnCommitCallbacks(execute=True), orTransactionTestCasewhen the commit itself is what is being tested. -
select_for_update()andatomic()checks that always pass. InsideTestCaseeverything already runs in a transaction; such tests needTransactionTestCase. -
Counting queries around set-up code. Keep
assertNumQueriestight around the request; a logged-in client adds session and user queries that are part of the real cost and belong in the expected number. -
DEBUGassumptions. Tests run withDEBUG = False, so a view that only works in debug mode (or a template relying on debug-only context) fails in tests and in production alike — which is the point.--debug-modeis for diagnosis only. -
Hardcoded primary keys.
/books/1/works until the sequence moves (always, after aTransactionTestCase); build URLs from the objects withreverse()orget_absolute_url(). -
Import-time queries. Queries at module level or in
AppConfig.ready()run before the test database exists. -
Missing templates in the test project.
assertRedirectsfetches the target, andfollow=Truerenders it; aTemplateDoesNotExistthere is a real bug in the project (here, the login template had to exist), not a test problem. -
Sync decorators on async tests. Third-party decorators may wrap the coroutine instead of the test; put
@async_to_syncinnermost. -
Shared resources under
--parallel. Files, ports and external services must be per-class or guarded withSerializeMixin.
Management commands are tested by calling them in-process with call_command(), as shown on
Management Commands & Shell; template tags, forms and signals each
have test examples on their own pages.
Source: Other test conditions and Management commands
Where the books are outdated
| What the books show (Django 1.11-2.x) | Current practice (Django 5.2/6.1) |
|---|---|
Rows created in |
|
|
|
|
|
|
|
|
|
|
|
Sequential runs only, or |
|
No async views to test. |
|
|
Every |
No tests at all (Melé). |
Tests for every view, form and permission rule, as on this page. |
The books' specific test mistakes are corrected in The books' faulty tests, corrected.
What changed recently
| Version | Change |
|---|---|
3.2 (2021) |
|
4.0 (2021-12) |
|
4.1 (2022-08) |
Parallel runs with the |
4.2 LTS (2023-04) |
|
5.0 (2023-12) |
|
5.1 (2024-08) |
|
5.2 LTS (2025-04) |
Django’s assertion frames hidden from tracebacks ( |
6.0 (2025-12) |
Parallel runs with the |
6.1 (2026-08) |
|
The rows come from the "Tests" sections of the release notes for each version and from the 6.1 testing topic pages.