Django: Parameterized 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 bad 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 parameterized 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.auth.models import User
from django.contrib.admin.sites import AdminSite
from django.contrib.admin.sites import all_sites
from django.db.models import Model
from django.test import TestCase
from django.urls import reverse
from parameterized import param
from parameterized import parameterized
def name_test(func: Callable[..., Any], param_num: int, param: param) -> str:
site = param.args[0]
model_admin = param.args[2]
return f"{func.__name__}_{site.name}_{str(model_admin).replace('.', '_')}"
each_model_admin = parameterized.expand(
[
(site, model, model_admin)
for site in all_sites
for model, model_admin in site._registry.items()
],
name_func=name_test,
)
class ModelAdminTests(TestCase):
user: User
@classmethod
def setUpTestData(cls):
cls.user = User.objects.create_superuser(
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 tests use the parameterized package to parameterize the tests. This package works with unittest
TestCase
classes, like those from Django’s testing framework. (The parameterization 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 is an undocumented piece of Django, but stable since its addition in 2017 (perhaps we could document it…).The
name_test
function generates a nice name for each test method, from the parameters.ModelAdminTests.setUpTestData
creates a superuser for the test. Since superusers have all permissions, they should be able to access all admin pages.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 being many tests, they be rapid, because they 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 important “change” view. This is because that page 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
If you try out these tests, or have suggestions on how to improve them, please do let me know.
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: