Back to home

Useful Django unit test patterns

Table of Contents

This is a collection of some common unit test patterns I find myself re-implementing across various projects. To save myself (and maybe some others) time in the future, I've decided to collect them all in one place.

I won't provide context for all of the examples here unless necessary. Where no further context is provided, it's safe to assume we're just working with standard models and factories.

I utilise factory_boy in all of my test suites. So any references to factories will be using this package.

Using Django Ninja's test client with Wagtail

When using django-ninja and Wagtail, you can only use the TestClient for requests if you set up a default site within the context of the test. Otherwise, Wagtail will make the request fail.

from django.test import TestCase
from ninja.testing import TestClient
from wagtail.models import Site

class AgencyPageAPI__TestCase(TestCase):
    def setUp(self):
        cache.clear()
        self.client = TestClient(vendors_router)
        default_site = Site.objects.get(is_default_site=True) # The important part
        self.agency_index_page = AgencyIndexPageFactory(parent=default_site.root_page)

    def test_list_agencies(self):
        agency_page = AgencyPageFactory(parent=self.agency_index_page)

        response = self.client.get("/agencies")
        self.assertEqual(response.status_code, 200)

        data = response.json()
        self.assertEqual(data["count"], 1)
        self.assertEqual(data["items"][0]["id"], agency_page.id)
        self.assertEqual(data["items"][0]["title"], agency_page.title)

        AgencyPageFactory.create_batch(3, parent=self.agency_index_page)
        response = self.client.get("/agencies")
        self.assertEqual(response.status_code, 200)
        self.assertEqual(response.json()["count"], 4)

Testing the Django admin UI with requests

When customising the admin interface with custom templates or other modifications, it's still a good idea to include test coverage, even if it isn't client-facing.

import factory
from factory.django import DjangoModelFactory
from django.test import TestCase


class UserFactory(DjangoModelFactory):
    class Meta:
        model = User

    email = factory.Faker("email")
    first_name = factory.Faker("first_name")
    last_name = factory.Faker("last_name")
    is_staff = False
    is_active = True
    date_joined = factory.LazyFunction(lambda: now() - timedelta(days=30))
    password = factory.Faker("uuid4")


def make_staff_user(**kwargs):
    return UserFactory(is_superuser=False, is_staff=True, **kwargs)


class SetUpTenantView__permissions__TestCase(TestCase):
    def test_non_superuser_get_is_forbidden(self):
        user = make_staff_user()
        self.client.force_login(user)
        response = self.client.get(setup_url())
        self.assertEqual(response.status_code, 403)

    def test_non_superuser_post_is_forbidden(self):
        user = make_staff_user()
        self.client.force_login(user)
        response = self.client.post(setup_url(), data=valid_post_data())
        self.assertEqual(response.status_code, 403)

    def test_unauthenticated_get_redirects_to_login(self):
        response = self.client.get(setup_url())
        self.assertEqual(response.status_code, 302)
        self.assertIn("login", response["Location"])

Using patch to test if a function is called

Let's say you have a function (f1) which should call another function (f2) under certain criteria. Instead of testing f2's functionality within f1's tests, I prefer to simply assert that f2 is called when it should be, and then let f2's own test coverage handle what it does afterwards.

This example is asserting that a function located in tenants/services.py called create_example_pathway_for_site is/isn't called under certain criteria.

from unittest.mock import patch
from django.test import TestCase
from tenants.tests.factories import ApplicationFactory

class SetupTenant__TestCase(TestCase):
    def test_example_not_called_without_admissions_app(self):
        other_app = ApplicationFactory(app_label="billing")
        with patch("tenants.services.create_example_pathway_for_site") as mock_fn:
            setup_tenant(
                org_name="Billing Org",
                org_identifier="BIO",
                site_name="Billing Site",
                site_identifier="BIS",
                user_email="[email protected]",
                user_first_name="",
                user_last_name="",
                org_apps=[other_app],
            )
            mock_fn.assert_not_called()

    def test_example_called_with_admissions_app(self):
        admissions_app = Application.objects.get(app_label="admissions")
        with patch("tenants.services.create_example_pathway_for_site") as mock_fn:
            result = setup_tenant(
                org_name="Admissions Org",
                org_identifier="ADO",
                site_name="Admissions Site",
                site_identifier="ADS",
                user_email="[email protected]",
                user_first_name="",
                user_last_name="",
                org_apps=[admissions_app],
            )
            mock_fn.assert_called_once_with(result["site"])

Patching a library's function within the context of a test

This is slightly different to the above example in two ways:

  1. We're patching the locale.currency method only within the context of the file we're testing, even thought locale is a separate library.
  2. We're using the decorator version of patch.

This example is based on some work I did for outputting prices based on the locale of the environment. Using patch meant that I could emulate a specific locale without tests failing because a certain locale wasn't installed on the host machine.

# events/utils/helpers.py
import locale

def format_currency(value: int | float, trim_whole_numbers: bool = False) -> str:
    price = locale.currency(value, grouping=True)
    if trim_whole_numbers:
        price = price.replace(".00", "")
    return price
# events/tests/test_utils.py
from unittest.mock import patch
from django.test import TestCase
from events.utils.helpers import format_currency

class Helpers__format_currency__TestCase(TestCase):
    @patch("events.utils.helpers.locale")
    def test_calls_locale_modules_currency_formatting(self, mock_locale):
        mock_locale.currency.return_value = "£1,234.50"

        result = format_currency(1234.5)

        self.assertEqual(result, "£1,234.50")
        mock_locale.currency.assert_called_once_with(1234.5, grouping=True)

    @patch("events.utils.helpers.locale")
    def test_trims_whole_numbers(self, mock_locale):
        mock_locale.currency.return_value = "£10.00"

        result = format_currency(10, trim_whole_numbers=True)

        self.assertEqual(result, "£10")
        mock_locale.currency.assert_called_once_with(10, grouping=True)

    @patch("events.utils.helpers.locale")
    def test_does_not_trim_whole_numbers_when_flag_false(self, mock_locale):
        mock_locale.currency.return_value = "£10.00"

        result = format_currency(10, trim_whole_numbers=False)

        self.assertEqual(result, "£10.00")
        mock_locale.currency.assert_called_once_with(10, grouping=True)

    @patch("events.utils.helpers.locale")
    def test_does_not_trim_not_whole_numbers(self, mock_locale):
        mock_locale.currency.return_value = "£10.50"

        result = format_currency(10.5, trim_whole_numbers=True)

        self.assertEqual(result, "£10.50")
        mock_locale.currency.assert_called_once_with(10.5, grouping=True)

Patching Django settings

Let's say that you have some functionality which is configured via the settings module for your django environment. This pattern allows you to patch the settings options directly within the tests.

# tenants/validators.py

import validators
from django.conf import settings


def is_valid_domain(domain: str) -> bool:
    """Return True if domain passes validation or is whitelisted."""
    return bool(validators.domain(domain)) or domain in settings.TENANT_DOMAIN_WHITELIST
# tenants/test_validators.py

from unittest.mock import patch

from django.conf import settings
from django.test import TestCase

from tenants.validators import is_valid_domain


class IsValidDomain__TestCase(TestCase):
    ...

    def test_uses_domains_whitelist(self):
        with self.subTest("Domain not in whitelist should be invalid"):
            with patch.object(settings, "TENANT_DOMAIN_WHITELIST", []):
                self.assertFalse(is_valid_domain("127.0.0.1"))

        with self.subTest("Domain in whitelist should be valid"):
            with patch.object(settings, "TENANT_DOMAIN_WHITELIST", ["127.0.0.1"]):
                self.assertTrue(is_valid_domain("127.0.0.1"))

Related blogs

  • writing-wagtail-streamfield-content-migrations
    Writing Wagtail StreamField content migrations

    When you drastically change one of your StreamFields and generate a migration for your new structure, the migration won't actually update what's already in your database. It'll describe the new, intended structure behind the fields, but the JSON data itself will remain exactly as it is.

    18 February 2026
  • directly-filtering-wagtail-pages
    Directly filtering Wagtail parent/child pages

    In Wagtail, pages don't have a typical, direct relationship like you'd expect in Django. Instead of using foreign keys to link them, each page has a `path` and a `depth` attribute...

    24 November 2025
  • django-wagtail-test-factories
    Django and Wagtail test factories

    A factory is a system that lets you easily mock data for your tests and creates as many records as you need for your test cases.

    5 November 2025