Django: Parameterized tests for all model admin classes

I’ve got a lovely bunch of parameterized 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 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:

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.


Subscribe via RSS, Twitter, Mastodon, or email:

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

Related posts:

Tags: ,