django-admin-tests

Automatic admin smoke-test coverage for Django projects. Better than nothing.

Why

Admin pages break quietly. A renamed field in list_display, a get_queryset that blows up on a related lookup, a permission tweak that locks out the wrong people — none of it shows up until someone opens the page.

This package asserts that every ModelAdmin you register still serves its changelist, add and change views — and still saves what its add and change forms submit — as part of your test run, under either manage.py test or pytest. No test per model.

A GET only proves the form renders. The _add_post and _change_post methods submit it, which is the only way to reach a save_model() that raises, a clean() that crashes rather than merely rejecting, an inline formset that doesn't round-trip, or a field the database requires but the form omits. A successful admin POST is a 302; a 200 means the form came back with errors, and the failure reports them.

Install

pip install django-admin-tests

Requires Python 3.10+ and Django 4.2+. The only runtime dependency is Django itself.

Quickstart

Put this in any test module your runner already collects:

from django_admin_tests import testcases


class AdminSmokeTest(testcases.AdminSmokeTestCase):
    pass

That's the whole setup. Run your tests as usual:

pytest
# or
python manage.py test

The examples import the module, not the class, on purpose. Test discovery scans the whole module namespace for TestCase subclasses, so importing the name directly collects the un-customized base class as a test of its own, alongside your subclass:

from django_admin_tests.testcases import AdminSmokeTestCase  # base class runs too


class AdminSmokeTest(AdminSmokeTestCase):
    pass

Customizing

Subclassing is also how you adjust behavior:

from django_admin_tests import testcases

from myapp.models import InternalReport, LegacyThing


class AdminSmokeTest(testcases.AdminSmokeTestCase):
    # Admins that intentionally deny access:
    model_allowed_status_codes = {InternalReport: {403}}
    # Admins to skip entirely:
    excluded_models = {LegacyThing}
AttributeDefaultPurpose
admin_siteadmin.siteTest a custom AdminSite
allowed_status_codes{200}Globally accepted statuses
model_allowed_status_codes{}Per-model status overrides
excluded_modelsNoneModels to skip entirely
user_factoryNoneSupply the authenticating user

pytest plugin

If you use pytest, the bundled plugin can collect the smoke tests without you writing a test file at all. It is opt-in — installing this package will never silently add tests to your suite:

# pyproject.toml
[tool.pytest.ini_options]
django_admin_tests_auto = true

There's no class to subclass in this mode, so configure it through Django settings instead.

Settings

SettingDefaultPurpose
ADMIN_TESTS_ALLOWED_STATUS_CODES{200}Globally accepted statuses
ADMIN_TESTS_MODEL_ALLOWED_STATUS_CODES{}Per-model overrides, keyed "app_label.ModelName"
ADMIN_TESTS_EXCLUDE[]Models to skip, same key format
# settings.py
ADMIN_TESTS_MODEL_ALLOWED_STATUS_CODES = {"myapp.InternalReport": [403]}
ADMIN_TESTS_EXCLUDE = ["myapp.LegacyThing"]

Resolution order is class attribute → Django setting → built-in default, so a subclass always wins over settings.

Change views need an object

To load a change view, there has to be something to change. An instance is resolved per model in this order:

If none of those work — most often a required self-referential or circular foreign key — that model's change-view check is skipped with a warning, not failed. Register a factory to cover it:

from django_admin_tests import register_factory

from myapp.models import Tricky


def make_tricky():
    return Tricky.objects.create(...)


register_factory(Tricky, make_tricky)

Call it anywhere that runs at startup — an AppConfig.ready(), or conftest.py. The auto-builder is written in-house and pulls in no third-party dependency.

Add forms that can't be filled in automatically

The add-view POST has nothing to round-trip, so every value is synthesised from your form's fields. A wrong guess would fail your admin for our mistake, so the payload is validated against the admin's own form before it is sent. If it doesn't validate the model is skipped, never failed, with a warning naming the fields that defeated it:

Skipping Order add POST: could not synthesise a payload its admin form
accepts (customer: This field is required.). Register one with
django_admin_tests.register_add_payload(Order, ...) to cover it.

Supply the payload yourself to cover it:

from django_admin_tests import register_add_payload

from myapp.models import Order


register_add_payload(Order, lambda: {"customer": 1, "total": "9.99"})

Confirmation-field pairs are handled for you: every field rendered with a PasswordInput gets the same value, so auth.User and similar forms validate without a registered payload.

A successful add POST creates a row and fires its signals. Database writes roll back with the test's transaction, but signals are not transactional — exclude any model whose post_save reaches an external system.

Custom user models

The test client authenticates as a superuser it creates itself, using the default User fields. If your AUTH_USER_MODEL needs something else, supply a factory:

class AdminSmokeTest(testcases.AdminSmokeTestCase):
    user_factory = staticmethod(my_superuser_factory)

Turning it off

The tests are tagged django_admin_tests:

python manage.py test --exclude-tag=django_admin_tests
pytest -m "not django_admin_tests"

Known limitations