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:
- We're patching the
locale.currencymethod only within the context of the file we're testing, even thoughtlocaleis a separate library. - 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"))