Metadata-Version: 2.5
Name: django-admin-tests
Version: 0.3.0
Summary: Automatic admin smoke-test coverage for Django projects.
Project-URL: Homepage, https://github.com/vinkomlacic/django-admin-tests
Project-URL: Repository, https://github.com/vinkomlacic/django-admin-tests
Project-URL: Issues, https://github.com/vinkomlacic/django-admin-tests/issues
Project-URL: Changelog, https://github.com/vinkomlacic/django-admin-tests/blob/master/CHANGELOG.md
Author-email: Vinko Mlačić <vinkomlacic@outlook.com>
License-Expression: MIT
License-File: LICENSE
Keywords: admin,django,django-admin,pytest,smoke-test,testing
Classifier: Framework :: Django
Classifier: Framework :: Django :: 4.2
Classifier: Framework :: Django :: 5.2
Classifier: Framework :: Django :: 6.0
Classifier: License :: OSI Approved :: MIT License
Classifier: Programming Language :: Python :: 3
Classifier: Programming Language :: Python :: 3.10
Classifier: Programming Language :: Python :: 3.11
Classifier: Programming Language :: Python :: 3.12
Classifier: Programming Language :: Python :: 3.13
Classifier: Programming Language :: Python :: 3.14
Requires-Python: >=3.10
Requires-Dist: django>=4.2
Provides-Extra: dev
Requires-Dist: build; extra == 'dev'
Requires-Dist: coverage; extra == 'dev'
Requires-Dist: pytest; extra == 'dev'
Requires-Dist: pytest-django; extra == 'dev'
Requires-Dist: ruff; extra == 'dev'
Requires-Dist: twine; extra == 'dev'
Provides-Extra: pytest
Requires-Dist: pytest; extra == 'pytest'
Description-Content-Type: text/markdown

# django-admin-tests

[![CI](https://github.com/vinkomlacic/django-admin-tests/actions/workflows/ci.yml/badge.svg)](https://github.com/vinkomlacic/django-admin-tests/actions/workflows/ci.yml)

Automatic admin smoke-test coverage for Django projects.

📖 **[Documentation](https://vinkomlacic.github.io/django-admin-tests/)**

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
...
```

## What the POST checks catch

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_fields` mismatch that only bites on save
- a field required by the database but missing from the form
- inline formsets that don't round-trip
- `post_save` signals 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_CODES` applies 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.

## Install

```bash
pip install django-admin-tests
```

Requires Python 3.10+ and Django 4.2+.

## Usage

There are two ways in. Both run under either test runner.

### Option 1 — subclass it (works everywhere)

Put this in any test module your runner already collects:

```python
from django_admin_tests import testcases


class AdminSmokeTest(testcases.AdminSmokeTestCase):
    pass
```

That's it. Subclassing is also how you customize behavior:

```python
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 `TestCase` subclasses, so
> `from django_admin_tests.testcases import AdminSmokeTestCase` collects the
> un-customized base class as a test of its own, alongside your subclass.
> Going through `testcases.AdminSmokeTestCase` avoids that.

### Option 2 — pytest plugin (no test file needed)

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:

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

Since there's no class to subclass in this mode, configure it through
Django settings instead (see below).

## Settings

| 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 |

```python
# 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.

## Running one model's tests

Because each model gets its own methods, you can iterate on a single admin
without re-running the rest:

```bash
pytest -k shop_product
python manage.py test myapp.tests.AdminSmokeTest.test_admin_smoke_shop_product_changelist
```

You 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:

```python
class AdminSmokeTest(testcases.AdminSmokeTestCase):
    def test_admin_smoke_shop_product_changelist(
        self,
    ): ...  # your own assertions for this one admin
```

## 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:

1. A factory you registered for that model
2. Any existing row
3. 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:

```python
# 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.

## 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'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:

```python
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.

## Turning it off

The tests are tagged `django_admin_tests`:

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

## 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:

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

## Custom admin sites

```python
from myproject.admin import my_site


class AdminSmokeTest(testcases.AdminSmokeTestCase):
    admin_site = my_site
```

## Known limitations

- 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. Use `register_factory` for those, or `register_add_payload`
  for add forms.
- A field named in `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.
- A required `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.
- Many-to-many fields are submitted empty on the add POST — selecting rows
  could violate through-model or business constraints.
- Custom `AUTH_USER_MODEL` support is the `user_factory` hook 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 --parallel` won't spread these across processes. Django
  distributes work per `TestCase` *class*, 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.baz` vs `foo.bar_baz`) would generate the same
  method name. That raises `ImproperlyConfigured` rather than silently
  dropping one — exclude one of them to proceed.

## Releasing

Versions are derived from git tags via `hatch-vcs` — nothing to bump in
`pyproject.toml`. To cut a release:

1. In `CHANGELOG.md`, retitle `## [Unreleased]` to `## [X.Y.Z] - YYYY-MM-DD`
   and add a fresh empty `## [Unreleased]` section above it.
2. Commit that change, then tag the commit with the bare version — **no
   `v` prefix** — and push both: `git tag X.Y.Z && git push origin master --tags`.
3. 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.

## License

MIT — see [LICENSE](LICENSE).
