Writing good tests

Django testing best practices

How to write fast, reliable Django tests: TestCase vs TransactionTestCase, setUpTestData, model_bakery, force_login, test settings, assertNumQueries and parallel runs.

A good Django test suite is fast enough that you run it all the time and strict enough that a green run means the code works. Most Django suites miss one of the two for the same handful of reasons: the wrong test case class, data rebuilt for every test, slow production settings, and views tested with empty pages.

This page is the checklist. Each item links to a guide that goes deeper where speed is the main concern.

Pick the right test case class

Django has three test case classes, and the choice decides how the database is reset between tests.

Class Database Reset between tests Use it for
SimpleTestCase Queries are refused Nothing to reset Pure functions, forms without models, template tags, URL resolving
TestCase Allowed Transaction rolled back Almost everything
TransactionTestCase Allowed Every table truncated Code that needs real commits, several connections, or a live server

TestCase wraps the class in a transaction and every test in a savepoint, then rolls back: resetting costs almost nothing. TransactionTestCase lets your code commit and then flushes every table after each test, which gets slower with every model you add. pytest-django has the same split: @pytest.mark.django_db behaves like TestCase, @pytest.mark.django_db(transaction=True) and the live_server fixture behave like TransactionTestCase.

Default to TestCase and justify every exception. Use TestCase instead of TransactionTestCase covers the migration, including the common reason people reach for TransactionTestCase in the first place: transaction.on_commit callbacks (see below).

Build data once with setUpTestData

setUp runs before every test. setUpTestData runs once per class, inside the class-level transaction, and every test still starts from the same rows because each test rolls back to a savepoint:

from django.test import TestCase
from model_bakery import baker

from shop.models import Order, Product


class OrderTotalTests(TestCase):
    @classmethod
    def setUpTestData(cls):
        cls.customer = baker.make("auth.User")
        cls.product = baker.make(Product, price=10)
        cls.order = baker.make(Order, customer=cls.customer)

    def test_empty_order_total(self):
        self.assertEqual(self.order.total(), 0)

    def test_total_with_lines(self):
        self.order.lines.create(product=self.product, quantity=3)
        self.assertEqual(self.order.total(), 30)

Since Django 3.2, attributes assigned in setUpTestData are deep-copied for each test, so a test that changes self.order in memory doesn't affect the next one. See setUpTestData instead of setUp for the details and setUpTestData deep copies for when that copy gets expensive.

Create data with factories

JSON fixture files rot: every new required field breaks them, and nobody can tell which test needs which row. Create data in the test with a factory instead, and give it only the fields the test is about.

  • model_bakery fills required fields automatically: baker.make(Order, status="paid"). Use baker.prepare() for unsaved instances and baker.make(Order, _quantity=50, _bulk_create=True) for many rows.
  • factory_boy gives you explicit, reusable defaults with DjangoModelFactory, SubFactory and traits. Use build() when the test doesn't need the row in the database.

Never hardcode primary keys (Order.objects.get(pk=1), "/orders/1/"). Sequences aren't reset between tests and differ between databases and parallel workers, so use order.pk and reverse(). If factories show up in your profile, read faster test factories.

Log in with force_login

client.login() runs the full authentication flow, including password hashing. Unless the test is about logging in, attach the user directly:

def setUp(self):
    self.client.force_login(self.customer)

Pair it with a fast password hasher in test settings. Use a fast password hasher in tests explains why this is often the biggest single speed-up in a Django suite.

Use test settings

Keep a settings module for tests (DJANGO_SETTINGS_MODULE=myproject.settings.test, or --ds / DJANGO_SETTINGS_MODULE in your pytest config) that changes what production needs and tests don't:

# myproject/settings/test.py
from .base import *  # noqa: F403

PASSWORD_HASHERS = ["django.contrib.auth.hashers.MD5PasswordHasher"]

CACHES = {"default": {"BACKEND": "django.core.cache.backends.locmem.LocMemCache"}}

STORAGES = {
    "default": {"BACKEND": "django.core.files.storage.InMemoryStorage"},
    "staticfiles": {"BACKEND": "django.contrib.staticfiles.storage.StaticFilesStorage"},
}

# With app.config_from_object("django.conf:settings", namespace="CELERY")
CELERY_TASK_ALWAYS_EAGER = True
CELERY_TASK_EAGER_PROPAGATES = True

You don't need to configure email. Django's test runner and pytest-django both switch EMAIL_BACKEND to the locmem backend, so sent messages land in django.core.mail.outbox (or pytest-django's mailoutbox fixture) and the outbox is emptied before each test. They also force DEBUG = False.

Each of these has its own guide: in-memory cache, Celery eager mode, and debug toolbar, DEBUG and static storage. Clear the locmem cache between tests (cache.clear() in setUp or an autouse fixture) so cached values don't leak from one test into another.

Test views with real data

A view test against an empty database proves very little. The template's {% for %} loop never runs, so a broken {% url %} tag, a missing attribute or an N+1 query inside the loop only shows up in production. Create the rows the page lists, then assert on what the user would see:

from django.test import TestCase
from django.urls import reverse
from model_bakery import baker

from shop.models import Order


class OrderListViewTests(TestCase):
    @classmethod
    def setUpTestData(cls):
        cls.customer = baker.make("auth.User")
        cls.orders = baker.make(Order, customer=cls.customer, _quantity=3)

    def setUp(self):
        self.client.force_login(self.customer)

    def test_lists_the_customers_orders(self):
        response = self.client.get(reverse("order-list"))

        self.assertEqual(response.status_code, 200)
        for order in self.orders:
            self.assertContains(response, reverse("order-detail", args=[order.pk]))

Prefer the test client over calling view functions with a RequestFactory for these tests: the client runs URL routing, middleware and template rendering, which is where the bugs hide.

Lock in query counts with assertNumQueries

Once a view has real data, pin how many queries it makes. Create more rows than the page needs and assert the count, so a missing select_related() fails the test instead of slowing down production:

def test_order_list_query_count(self):
    baker.make(Order, customer=self.customer, _quantity=20)

    with self.assertNumQueries(4):
        self.client.get(reverse("order-list"))

The number is whatever the view needs today (session, user, orders, a prefetch); what matters is that it stays the same with 3 rows or 20. In pytest, use pytest-django's django_assert_num_queries fixture. Find and fix N+1 queries shows how to find the offenders.

Run on_commit callbacks in TestCase

TestCase never commits, so transaction.on_commit() callbacks (send the email, enqueue the task) never fire. That's not a reason to switch to TransactionTestCase. Capture and run them:

from django.core import mail


def test_signup_sends_welcome_email(self):
    with self.captureOnCommitCallbacks(execute=True) as callbacks:
        self.client.post(reverse("signup"), {"email": "[email protected]"})

    self.assertEqual(len(callbacks), 1)
    self.assertEqual(len(mail.outbox), 1)

pytest-django has the same as the django_capture_on_commit_callbacks fixture.

Override settings per test, not globally

Change a setting for one test or class with @override_settings(...), or with self.settings(...): inside a test. Both restore the original value afterwards and send the setting_changed signal so Django's caches of settings are reset. pytest-django's settings fixture does the same. Never assign to django.conf.settings directly in a test: the change outlives the test and makes the next one depend on the order tests run in (see test isolation).

Control time with time-machine

Code that depends on "now" (expiry dates, trial periods, reports for "this month") needs a fixed clock. time-machine works as a class decorator, a method decorator or a context manager:

import datetime as dt

import time_machine
from django.test import TestCase


@time_machine.travel(dt.datetime(2026, 3, 1, 9, 0, tzinfo=dt.UTC), tick=False)
class TrialExpiryTests(TestCase):
    def test_trial_expires_after_14_days(self): ...

It's much cheaper to start than freezegun: see replace freezegun with time-machine. Never use time.sleep() to wait for time to pass (tests are sleeping).

Never touch the network

A test that calls a real API is slow and fails whenever that API, DNS or the CI network has a bad minute. Mock HTTP at the transport (responses, respx, pytest-httpx) and block everything else so a forgotten mock fails loudly. Mock and block real network calls has the setup, including an autouse fixture that rejects any host but localhost.

Reuse the database and run in parallel

Two flags change how long you wait more than anything in the test code:

  • Keep the test database between runs: pytest --reuse-db with pytest-django, manage.py test --keepdb with Django's runner. CI should still build it from scratch to catch broken migrations. See stop re-running migrations.
  • Run on every core: pytest -n auto with pytest-xdist, or manage.py test --parallel auto. Each worker gets its own test database. See run tests in parallel with pytest-xdist, and run migrations once when every worker migrating its own database adds up.

Parallel runs are also a free isolation check: a test that only passes after another test has run will start failing.

Measure, then fix

Guessing which of these matters for your suite wastes time. flakytest run profiles the suite and ranks the fixes by the seconds per run they'd save: password hashing, setUp work that belongs in setUpTestData, TransactionTestCase flushes, migrations, N+1 queries. After a change, flakytest compare shows what got faster and flags tests whose outcome changed or that stopped running. See the docs for details.

Further reading

Related guides

Updated October 18, 2026 · Suggest a change