Django: Parametrized tests for all model admin classes

Here’s an application of “test smarter, not harder”, as per Luke Plant’s post. I came up with this recently whilst working on my client Silvr’s project, and I’m pretty proud of it. It should apply to any project using Django’s admin.
When you declare a Django ModelAdmin
class, the built-in system checks ensure that various attributes are well-defined, using the right data types and values. But they can’t cover everything because there is so much flexibility. So it’s possible to have, for example, a non-existent field name in search_fields
.
You can ensure that a ModelAdmin
class works properly by writing tests to load its various views. But rather than write individual tests, you can “push down the loop” and write parametrized tests that run on every ModelAdmin
.
The below code tests all model admins’ “changelist” and “add” pages. It’s all generic, so you should be able to copy-paste it into most Django projects without modification.
from __future__ import annotations
from collections.abc import Callable
from http import HTTPStatus
from typing import Any
from django.contrib.admin.sites import AdminSite
from django.contrib.admin.sites import all_sites
from django.contrib.auth.models import User
from django.db.models import Model
from django.test import TestCase
from django.urls import reverse
from unittest_parametrize import param
from unittest_parametrize import parametrize
from unittest_parametrize import ParametrizedTestCase
each_model_admin = parametrize(
"site,model,model_admin",
[
param(
site,
model,
model_admin,
id=f"{site.name}_{str(model_admin).replace('.', '_')}",
)
for site in all_sites
for model, model_admin in site._registry.items()
],
)
class ModelAdminTests(ParametrizedTestCase, TestCase):
user: User
@classmethod
def setUpTestData(cls):
cls.user = User.objects.create_superuser(
username="admin", email="admin@example.com", password="test"
)
def setUp(self):
self.client.force_login(self.user)
def make_url(self, site: AdminSite, model: type[Model], page: str) -> str:
return reverse(
f"{site.name}:{model._meta.app_label}_{model._meta.model_name}_{page}"
)
@each_model_admin
def test_changelist(self, site, model, model_admin):
url = self.make_url(site, model, "changelist")
response = self.client.get(url, {"q": "example.com"})
assert response.status_code == HTTPStatus.OK
@each_model_admin
def test_add(self, site, model, model_admin):
url = self.make_url(site, model, "add")
response = self.client.get(url)
assert response.status_code in (
HTTPStatus.OK,
HTTPStatus.FORBIDDEN, # some admin classes blanket disallow "add"
)
Notes:
The module imports the default
User
model. If your project uses a custom user model, switch the import to that.The tests use the unittest-parametrize package to parametrize the tests. This package works with unittest
TestCase
classes, like those from Django’s testing framework. (And the parametrization also works within pytest.)each_model_admin
is a test method decorator that expands the test to run on every model admin class, in every admin site. Most projects use a single admin site, the default one, but it’s possible to have multiple sites.The definition uses the
all_sites
weak set, which contains all admin site instances. This set is an undocumented piece of Django, but stable since its addition in 2017 (perhaps we could document it… I suggested to do so in Ticket #34419).ModelAdminTests.setUpTestData
creates a superuser for the test. Since superusers have all permissions, they should be able to access all admin pages.If you use a custom user model, you’ll need to change the import to use your model.
The two tests,
test_changelist
andtest_add
, fetch the respective pages from theModelAdmin
classes. These are the two views that all model admins contain that respond toGET
requests and don’t need any pre-populated data.The two tests cover many model admin attributes, such as
list_display
and related methods, or customizations inget_queryset()
.test_changelist
checks thatsearch_fields
is well-defined by adding theq
query parameter.test_add
allows “forbidden” status codes, because sometimes admin classes disallow access to the “add” page. This is done with an overriddenhas_add_permission()
:class BookAdmin(admin.ModelAdmin): ... def has_add_permission(self, request, obj=None): # Read-only return False
Running the tests
When running the test case, you can see two tests per model admin class:
$ pytest example/tests/test_admin.py -vv
======================== test session starts =========================
...
example/tests/test_admin.py::ModelAdminTests::test_add_admin_example_BookAdmin PASSED [ 0%]
example/tests/test_admin.py::ModelAdminTests::test_changelist_admin_example_BookAdmin PASSED [ 0%]
...
Despite containing many tests, the test case is rapid because the tests don’t create any data in the database. On my client Silvr’s project, the test case currently runs 266 tests in ~15 seconds.
Factory-ification
These tests do not test the critical “change” view. This view is missing because it requires a model instance to load, and there’s no general way of automatically creating model instances for tests.
Some projects use factory functions, or a factory package like factory boy, to create test data. In such cases, it may be possible to write a “change” test that looks up the respective factory, uses it to create an instance, and then tests the “change” view with that.
Fin
Please let me know if you try out these tests or have suggestions on improving them.
And remember—test smarter, not harder,
—Adam
If your Django project’s long test runs bore you, I wrote a book that can help.
One summary email a week, no spam, I pinky promise.
Related posts: