Automatic admin smoke-test coverage for Django projects. Better than nothing.
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.
pip install django-admin-tests
Requires Python 3.10+ and Django 4.2+. The only runtime dependency is Django itself.
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
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}
| Attribute | Default | Purpose |
|---|---|---|
admin_site | admin.site | Test a custom AdminSite |
allowed_status_codes | {200} | Globally accepted statuses |
model_allowed_status_codes | {} | Per-model status overrides |
excluded_models | None | Models to skip entirely |
user_factory | None | Supply the authenticating user |
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.
| Setting | Default | Purpose |
|---|---|---|
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.
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.
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.
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)
The tests are tagged django_admin_tests:
python manage.py test --exclude-tag=django_admin_tests
pytest -m "not django_admin_tests"
save() or
clean() requirements. Use register_factory for those, or
register_add_payload for add forms.
fields/fieldsets but kept out of the form
— populated in save(), by a signal, or by a hook — is treated
as valid and passes. Django raises while rendering that admin, so there's no response
to check and the GET's status isn't asserted; the POST checks cover those models
instead.
CharField/TextField excluded from the form saves
as '' rather than failing. Django passes excluded fields to
full_clean(exclude=...), so blank is never checked and the database
accepts the empty string. Only relations and non-string NOT NULL columns
surface as an error.
AUTH_USER_MODEL support is the user_factory hook only;
there's no auto-detection of required fields.