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)

SimpleTestCase; it refuses database queries so a hidden query fails loudly.

The test case classes

Almost everything else (models, views, forms with models)

TestCase: every test runs in a transaction that is rolled back, which is fast and isolated.

The test case classes

Shared rows for all tests of a class

setUpTestData() (run once per class), not setUp() (run once per test).

setUpTestData: create rows once per class

Real commit/rollback behaviour, select_for_update(), on_commit really firing

TransactionTestCase (slower: tables are truncated after each test).

TransactionTestCase

A real HTTP server (browser tests, external clients)

LiveServerTestCase / StaticLiveServerTestCase and self.live_server_url.

LiveServerTestCase

Exercise a URL end to end (middleware, URLconf, view, template)

self.client (sync) or self.async_client (async); force_login() to skip the login form.

The test client

Call one view in isolation, without middleware

RequestFactory / AsyncRequestFactory; set request.user yourself.

RequestFactory: testing a view in isolation

Check redirects, templates, HTML, forms, query counts

assertRedirects, assertTemplateUsed, assertContains, assertFormError, assertNumQueries.

Assertions

Code that runs after commit (emails, tasks)

captureOnCommitCallbacks(execute=True) inside a TestCase.

Testing on_commit callbacks inside TestCase

Change a setting for one test

@override_settings(…​), self.settings(…​), @modify_settings(…​) for lists.

Changing settings in a test

Assert on sent email

django.core.mail.outbox (the runner swaps in the in-memory backend).

Email in tests: mail.outbox

Faster, stricter runs

--parallel, --shuffle, --keepdb, --tag/--exclude-tag, a test settings module with a fast password hasher.

Runner options

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.

flowchart LR RF[RequestFactory request] --> V TC[Test client / AsyncClient] --> H[Handler] LS[External client over HTTP] --> S[Live server thread] --> H H --> MW[Middleware] --> U[URL resolver] --> V[View] V --> T[Template / JSON] T --> R[Response + context + templates] R --> TC V --> DB[(Test database)]

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.

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.

catalog/models.py
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:

catalog/forms.py
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.

catalog/views.py
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")
catalog/urls.py
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.

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

SimpleTestCase

Queries are refused (an AssertionError is raised).

Nothing to isolate.

URLs, forms without models, template tags, helpers, settings-driven views.

TransactionTestCase

Allowed; real commits and rollbacks.

All tables truncated (flushed) after each test.

Code that depends on commit/rollback: select_for_update(), on_commit firing for real, several connections.

TestCase

Allowed; each test wrapped in atomic() blocks.

The transaction is rolled back after each test.

The default for anything that touches the database.

LiveServerTestCase

As TransactionTestCase.

As TransactionTestCase.

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.

classDiagram direction LR class unittest_TestCase["unittest.TestCase"] class SimpleTestCase { client, async_client assertContains, assertRedirects, ... databases = set() } class TransactionTestCase { fixtures, available_apps assertNumQueries, assertQuerySetEqual flush after each test } class TestCase { setUpTestData() captureOnCommitCallbacks() rollback after each test } class LiveServerTestCase { live_server_url } class StaticLiveServerTestCase unittest_TestCase <|-- SimpleTestCase SimpleTestCase <|-- TransactionTestCase TransactionTestCase <|-- TestCase TransactionTestCase <|-- LiveServerTestCase LiveServerTestCase <|-- StaticLiveServerTestCase

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.

catalog/tests/test_simple.py
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:

catalog/tests/test_db_guard.py
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.

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.

catalog/tests/test_views.py (set-up part)
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:

catalog/tests/test_transactions.py (TransactionTestCase part)
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 the post_migrate handlers 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 all TransactionTestCase classes 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_APPS excludes 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:

catalog/tests/test_transactions.py (live server part)
@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.

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 for get()/head(), as multipart/form-data for post() by default (open files in it are uploaded), or serialized with json.dumps() when content_type="application/json" and the value is a dict, list or tuple (the json_encoder client argument, DjangoJSONEncoder by default, controls that);

  • follow=True — follow redirects; the final response gets a redirect_chain list 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 as HTTP_ACCEPT_LANGUAGE="fr"-style keyword arguments, which still works;

  • query_params={…​} (Django 5.1+) — a query string for any method, including post().

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.

catalog/tests/test_client.py
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.

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.

What the response carries

The response is the view’s HttpResponse with extra attributes set by the test client:

Attribute Content

status_code, headers, content

As in a real response; content is bytes (response.text, Django 5.2+, decodes it).

json()

The body parsed as JSON (only for a JSON Content-Type).

context

The template context, looked up by key (response.context["book_list"]); with several templates a list of contexts.

templates

The list of Template objects rendered, in order (including extended and included ones).

resolver_match

The ResolverMatch of the URL (url_name, func, kwargs, …​).

request, wsgi_request

The request data, and the actual WSGIRequest the view received (an ASGIRequest with the async client).

client

The client that made the request.

exc_info

With raise_request_exception=False, the exception a view raised.

redirect_chain

With follow=True, the redirects followed.

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.

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:

catalog/tests/test_async.py
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="…​"); the headers= argument works the same in both clients.

  • follow=True is supported since Django 5.0, as are alogin(), aforce_login(), alogout() and asession().

  • Decorators on async tests must be async-aware. Django’s own (override_settings, tag, …​) are; a third-party decorator such as mock.patch may silently wrap the coroutine creation instead of the test. The documented workaround is to put @async_to_sync (from asgiref.sync) directly on the async def, below the other decorators.

Async views and the async ORM are explained on Async Support.

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.

catalog/tests/test_factory.py
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

assertContains(response, text, count=None, status_code=200, html=False)

The status code matches and text appears in the body (count times, if given); html=True compares as HTML.

assertNotContains(response, text, status_code=200, html=False)

The status code matches and text does not appear.

assertRedirects(response, expected_url, status_code=302, target_status_code=200, fetch_redirect_response=True)

The response redirects to expected_url, and (unless fetch_redirect_response=False) fetching that URL gives target_status_code.

assertTemplateUsed(response, template_name, count=None) / assertTemplateNotUsed(…​)

A template was (or was not) rendered; also usable as a context manager around the request.

assertFormError(form, field, errors) / assertFormSetError(formset, form_index, field, errors)

A bound form (or formset) has exactly these errors on field (None for non-field errors).

assertHTMLEqual, assertHTMLNotEqual, assertInHTML(needle, haystack, count=None), assertNotInHTML (5.1+)

HTML compared semantically: attribute order and insignificant whitespace are ignored.

assertJSONEqual(raw, expected_data) / assertJSONNotEqual

A JSON string (or bytes) parses to the expected Python data.

assertXMLEqual / assertXMLNotEqual, assertURLEqual(url1, url2)

XML compared semantically; URLs compared with query parameters in any order.

assertRaisesMessage(exc, message) / assertWarnsMessage(warning, message)

The exception (or warning) is raised and its message contains message.

assertFieldOutput(fieldclass, valid, invalid)

A form field cleans each valid input to the expected value and rejects each invalid input with the expected errors.

assertQuerySetEqual(qs, values, transform=None, ordered=True)

A queryset (or any iterable) equals values; TransactionTestCase and subclasses only.

assertNumQueries(num, func, *args, **kwargs)

Exactly num queries run, usually as a context manager; TransactionTestCase and subclasses only.

The view tests of the running example use most of them:

catalog/tests/test_views.py (BookViewTests tests)
    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: remove select_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 of setUp() 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.

  • assertRedirects fetches the target by default; fetch_redirect_response=False is needed when the target cannot be served in the test (an external URL, or a page whose template the test project lacks).

  • assertContains checks the status code too, so assertContains(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 same StreamingHttpResponse (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 old assertQuerysetEqual was removed in Django 5.1.

  • assertRaisesMessage does a substring match; to check an exact message use assertRaises and compare str(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:

catalog/tests/test_views.py (the happy path)
    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.

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": …​}) (or self.modify_settings(…​)) for list settings such as MIDDLEWARE and INSTALLED_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.

catalog/tests/test_settings.py
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.NAME at call time. A module-level alias such as MAINTENANCE = settings.MAINTENANCE_MODE is evaluated once at import and never sees the override — read settings inside functions.

  • Some settings are only read when Django starts. Overriding DATABASES is not recommended; overriding CACHES is 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 to django.test.signals.setting_changed.

  • Applied to a class, the decorators modify that class in place (they do not return a copy), and modify_settings is always applied after override_settings. They work on async test methods too.

  • @override_settings() followed by del settings.NAME inside the test simulates a missing setting.

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 the NAME of the alias (test_bookshelf for a PostgreSQL database called bookshelf). 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 print Creating 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-in postgres database.

  • The TEST dictionary inside an alias tunes the test database: NAME (another name, or a file path to make SQLite use a file), MIGRATE (False creates 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), CHARSET and COLLATION (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 TestCase tests, but a TransactionTestCase flush removes it unless serialized_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.

config/settings.py (PostgreSQL with explicit test options)
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

--parallel [N] / DJANGO_TEST_PROCESSES

Runs test case classes in N processes (auto or no value: one per core, Django 4.0+). Each process gets its own clone of the test database.

--shuffle [SEED]

Randomises the order inside the ordering groups (Django 4.0+); prints the seed so a failing order can be replayed.

--reverse, -r

Runs the test classes in reverse order (combinable with --shuffle).

--tag TAG / --exclude-tag TAG

Runs only (or skips) tests marked with @tag; both may be repeated and combined.

-k PATTERN

Runs test methods and classes whose names match, like unittest -k (Django 3.0+).

--keepdb

Keeps the test database between runs, applying only new migrations; skips creation and destruction.

--debug-sql, -d

Prints the SQL of failing tests (of all tests at -v 2), formatted with sqlparse since Django 4.2.

--debug-mode

Sets DEBUG = True for the run, to troubleshoot a failure that depends on it.

--pdb

Opens the debugger (ipdb if installed) on each error or failure; use without --parallel.

--buffer, -b

Hides the output of passing tests (works with --parallel since Django 4.0).

--timing

Prints database set-up, teardown and total run times (Django 3.2+).

--durations N

Lists the N slowest tests (Django 5.0+, needs Python 3.12+).

--no-faulthandler

Disables the faulthandler that prints a traceback if the interpreter crashes.

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

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.

Keeping the suite fast

A test suite that takes minutes stops being run. In rough order of payoff:

  1. Use TestCase rather than TransactionTestCase, and setUpTestData() rather than setUp() for rows.

  2. Use force_login() rather than login(), and a fast password hasher in a test settings module — the default PBKDF2 hasher is deliberately slow, and every create_user() and login() pays for it.

  3. Run with --parallel once the suite is isolated (prove it with --shuffle).

  4. Use --keepdb locally on PostgreSQL/MySQL.

  5. Keep media files in memory with InMemoryStorage so tests that upload files never touch the disk.

  6. Find the slow tests with --durations 10 and the set-up cost with --timing.

config/settings_test.py
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):

catalog/fixtures/sample_books.json
[
  {"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.

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:

catalog/tests/test_views.py (BookDeleteTests)
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.

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).

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 --shuffle in 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 locmem cache in the test settings and clear it in setUp() where it matters.

  • Expecting on_commit to fire in TestCase. It never does on its own; use captureOnCommitCallbacks(execute=True), or TransactionTestCase when the commit itself is what is being tested.

  • select_for_update() and atomic() checks that always pass. Inside TestCase everything already runs in a transaction; such tests need TransactionTestCase.

  • Counting queries around set-up code. Keep assertNumQueries tight 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.

  • DEBUG assumptions. Tests run with DEBUG = 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-mode is for diagnosis only.

  • Hardcoded primary keys. /books/1/ works until the sequence moves (always, after a TransactionTestCase); build URLs from the objects with reverse() or get_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. assertRedirects fetches the target, and follow=True renders it; a TemplateDoesNotExist there 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_sync innermost.

  • Shared resources under --parallel. Files, ports and external services must be per-class or guarded with SerializeMixin.

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.

Where the books are outdated

What the books show (Django 1.11-2.x) Current practice (Django 5.2/6.1)

Rows created in setUp() for every test.

setUpTestData() (since 1.8), with per-test isolation of the class attributes since 3.2.

client.login(username=…​, password=…​) in each test.

client.force_login(user), and aforce_login() in async tests (5.0+).

HTTP_ACCEPT_LANGUAGE="fr"-style header arguments.

headers={"accept-language": "fr"} (4.2+); query_params={…​} for query strings on any method (5.1+).

assertFormError(response, "form", "field", errors).

assertFormError(form, "field", errors) (4.1+; the response form was removed in 5.0).

assertQuerysetEqual(qs, [repr(obj), …​]) comparing string representations.

assertQuerySetEqual(qs, [obj, …​]) comparing objects (direct comparison 3.2+, new spelling 4.2+, old name removed in 5.1).

TransactionTestCase to test code that uses on_commit.

captureOnCommitCallbacks() inside TestCase (3.2+).

Sequential runs only, or --parallel without a count on Linux.

--parallel auto (4.0+), on macOS/Windows (spawn, 4.1+) and forkserver (6.0+); --shuffle (4.0+).

No async views to test.

async def tests, AsyncClient/self.async_client (3.1+), AsyncRequestFactory.

EMAIL_BACKEND replaced by locmem in tests.

Every MAILERS alias replaced by locmem (6.1); messages carry sent_using (6.1).

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)

captureOnCommitCallbacks(); setUpTestData() attributes deep-copied per test; --timing; faulthandler on by default.

4.0 (2021-12)

--shuffle; --parallel auto; --buffer with parallel runs; callbacks added by callbacks are captured too.

4.1 (2022-08)

Parallel runs with the spawn start method (macOS, Windows); assertFormError()/assertFormSetError() take form objects; nested durable atomic() raises in TestCase.

4.2 LTS (2023-04)

headers= on the clients and request factories; --debug-sql output formatted with sqlparse; assertQuerySetEqual spelling.

5.0 (2023-12)

alogin(), aforce_login(), alogout(), asession(); follow on AsyncClient; --durations (Python 3.12+).

5.1 (2024-08)

query_params= on the clients and request factories; assertNotInHTML(); haystacks in assertContains/assertInHTML failure messages; thread database connections refused in SimpleTestCase.

5.2 LTS (2025-04)

Django’s assertion frames hidden from tracebacks (--pdb lands in the test); fixture and serialized-rollback data available in TransactionTestCase.setUpClass().

6.0 (2025-12)

Parallel runs with the forkserver start method.

6.1 (2026-08)

assertContains()/assertNotContains() can be called repeatedly on a StreamingHttpResponse; the runner swaps every MAILERS alias to locmem and outbox messages gain sent_using.

The rows come from the "Tests" sections of the release notes for each version and from the 6.1 testing topic pages.