Django: Parametrized tests for all model admin classes

I’ve got a lovely bunch of parametrized tests, there they are a-standing in a row…

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:

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


Newly updated: my book Boost Your Django DX now covers Django 5.0 and Python 3.12.


Subscribe via RSS, Twitter, Mastodon, or email:

One summary email a week, no spam, I pinky promise.

Related posts:

Tags: ,