Automatic admin smoke-test coverage for Django projects.
π Documentation
Every ModelAdmin you register gets its changelist, add and change views
asserted to return 200, and its add and change forms submitted back β as
part of your test run, under either manage.py test or pytest. Admin pages
break quietly: a renamed field in list_display, a get_queryset that blows
up on a related lookup, a save_model that raises. This catches that without
you writing a test per model.
You get one test method per model and view, named after the model, so a broken admin fails under its own name rather than inside a shared loop:
test_admin_smoke_shop_product_changelist
test_admin_smoke_shop_product_add
test_admin_smoke_shop_product_change
test_admin_smoke_shop_product_add_post
test_admin_smoke_shop_product_change_post
test_admin_smoke_blog_article_changelist
...
A GET only proves the form renders. The _add_post and _change_post
methods submit it, which is the only way to reach:
save_model()/save_related()raising- a
clean()that crashes rather than merely rejecting - a
readonly_fieldsmismatch that only bites on save - a field required by the database but missing from the form
- inline formsets that don't round-trip
post_savesignals that break
The payload is built from your admin's own form β fields, fieldsets,
exclude, a custom form and readonly_fields are all honoured exactly as
the real view honours them. The change form round-trips an existing object;
the add form is filled in automatically.
A successful admin POST is a 302 redirect. A 200 means the form came
back with errors, and the failure message reports them.
Note:
ADMIN_TESTS_ALLOWED_STATUS_CODESapplies to GETs only. It defaults to{200}, which is the failure signal for a POST, so applying it there would invert the check. Per-model overrides (ADMIN_TESTS_MODEL_ALLOWED_STATUS_CODES,model_allowed_status_codes) do apply to both.
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.
pip install django-admin-testsRequires Python 3.10+ and Django 4.2+.
There are two ways in. Both run under either test runner.
Put this in any test module your runner already collects:
from django_admin_tests import testcases
class AdminSmokeTest(testcases.AdminSmokeTestCase):
passThat's it. Subclassing is also how you customize 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}Note: the examples import the module on purpose. Test discovery scans the whole module namespace for
TestCasesubclasses, sofrom django_admin_tests.testcases import AdminSmokeTestCasecollects the un-customized base class as a test of its own, alongside your subclass. Going throughtestcases.AdminSmokeTestCaseavoids that.
If you use pytest, the bundled plugin can collect the smoke tests without you writing anything. It's opt-in β installing this package will never silently add tests to your suite:
# pyproject.toml
[tool.pytest.ini_options]
django_admin_tests_auto = trueSince there's no class to subclass in this mode, configure it through Django settings instead (see below).
| Setting | Default | Purpose |
|---|---|---|
ADMIN_TESTS_ALLOWED_STATUS_CODES |
{200} |
Globally accepted response statuses |
ADMIN_TESTS_MODEL_ALLOWED_STATUS_CODES |
{} |
Per-model overrides, keyed "app_label.ModelName" |
ADMIN_TESTS_EXCLUDE |
[] |
Models to skip entirely, 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.
Excluded models keep their test methods and report as skipped, so an opt-out stays visible in your output rather than looking like a model that was never covered.
Because each model gets its own methods, you can iterate on a single admin without re-running the rest:
pytest -k shop_product
python manage.py test myapp.tests.AdminSmokeTest.test_admin_smoke_shop_product_changelistYou can also override a single model's check by defining a method with the generated name β yours wins, and the generated one isn't installed:
class AdminSmokeTest(testcases.AdminSmokeTestCase):
def test_admin_smoke_shop_product_changelist(
self,
): ... # your own assertions for this one adminTo load a change view, there has to be something to change. An instance is resolved per model in this order:
- A factory you registered for that model
- Any existing row
- A minimal instance built automatically from the model's fields
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:
# In your AppConfig.ready(), conftest.py, or anywhere that runs at startup
from django_admin_tests import register_factory
from myapp.models import Tricky
def make_tricky():
return Tricky.objects.create(...)
register_factory(Tricky, make_tricky)The auto-builder is in-house and dependency-free β installing this package pulls in nothing but Django.
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's 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.
The tests are tagged django_admin_tests:
python manage.py test --exclude-tag=django_admin_tests
pytest -m "not django_admin_tests"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)from myproject.admin import my_site
class AdminSmokeTest(testcases.AdminSmokeTestCase):
admin_site = my_site- Models whose change view can't be instantiated are skipped with a warning rather than failed. That's deliberate β a smoke test shouldn't fail because a model is awkward to construct β but it does mean an un-covered model can go unnoticed. The warning names it.
- The auto-builder doesn't try to satisfy custom
save()/clean()requirements. Useregister_factoryfor those, orregister_add_payloadfor add forms. - A field named in
fields/fieldsetsbut kept out of the form β populated insave(), 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. - A required
CharField/TextFieldexcluded from the form saves as''rather than failing. Django passes excluded fields tofull_clean(exclude=...), so blank is never checked and the database accepts the empty string. Only relations and non-stringNOT NULLcolumns surface as an error. - Many-to-many fields are submitted empty on the add POST β selecting rows could violate through-model or business constraints.
- Custom
AUTH_USER_MODELsupport is theuser_factoryhook only; there's no auto-detection of required fields. - The admin registry is read when the test class is created, so admins registered after that aren't covered. In practice registration happens during app loading, which always precedes test module import.
manage.py test --parallelwon't spread these across processes. Django distributes work perTestCaseclass, and all the generated methods live on one class, so the run stays single-process.- Two models whose app label and model name differ only in where the
underscore falls (
foo_bar.bazvsfoo.bar_baz) would generate the same method name. That raisesImproperlyConfiguredrather than silently dropping one β exclude one of them to proceed.
Versions are derived from git tags via hatch-vcs β nothing to bump in
pyproject.toml. To cut a release:
- In
CHANGELOG.md, retitle## [Unreleased]to## [X.Y.Z] - YYYY-MM-DDand add a fresh empty## [Unreleased]section above it. - Commit that change, then tag the commit with the bare version β no
vprefix β and push both:git tag X.Y.Z && git push origin master --tags. - Pushing the tag triggers
.github/workflows/release.yml, which builds the sdist/wheel, publishes to PyPI (via Trusted Publishing), and creates a GitHub Release using that CHANGELOG section as its notes.
MIT β see LICENSE.