pytest-django-admin-kit 1.0.0__tar.gz → 1.1.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {pytest_django_admin_kit-1.0.0 → pytest_django_admin_kit-1.1.0}/CHANGELOG.md +58 -0
- {pytest_django_admin_kit-1.0.0 → pytest_django_admin_kit-1.1.0}/PKG-INFO +62 -12
- {pytest_django_admin_kit-1.0.0 → pytest_django_admin_kit-1.1.0}/README.md +60 -10
- pytest_django_admin_kit-1.1.0/llms.txt +41 -0
- {pytest_django_admin_kit-1.0.0 → pytest_django_admin_kit-1.1.0}/pyproject.toml +2 -2
- {pytest_django_admin_kit-1.0.0 → pytest_django_admin_kit-1.1.0}/specification.md +321 -35
- {pytest_django_admin_kit-1.0.0 → pytest_django_admin_kit-1.1.0}/src/django_admin_kit/fields.py +192 -14
- {pytest_django_admin_kit-1.0.0 → pytest_django_admin_kit-1.1.0}/src/django_admin_kit/pages.py +122 -29
- {pytest_django_admin_kit-1.0.0 → pytest_django_admin_kit-1.1.0}/src/django_admin_kit/session.py +44 -20
- pytest_django_admin_kit-1.1.0/src/django_admin_kit/widgets.py +313 -0
- {pytest_django_admin_kit-1.0.0 → pytest_django_admin_kit-1.1.0}/tests/project/shop/admin.py +51 -1
- pytest_django_admin_kit-1.1.0/tests/project/shop/migrations/0005_product_help_text.py +26 -0
- pytest_django_admin_kit-1.1.0/tests/project/shop/migrations/0006_feed_section.py +17 -0
- pytest_django_admin_kit-1.1.0/tests/project/shop/migrations/0007_feed_categories.py +16 -0
- {pytest_django_admin_kit-1.0.0 → pytest_django_admin_kit-1.1.0}/tests/project/shop/models.py +16 -4
- {pytest_django_admin_kit-1.0.0 → pytest_django_admin_kit-1.1.0}/tests/project/shop/widgets.py +11 -0
- {pytest_django_admin_kit-1.0.0 → pytest_django_admin_kit-1.1.0}/tests/test_custom_user.py +20 -1
- {pytest_django_admin_kit-1.0.0 → pytest_django_admin_kit-1.1.0}/tests/test_fields.py +140 -10
- pytest_django_admin_kit-1.1.0/tests/test_fieldsets.py +268 -0
- pytest_django_admin_kit-1.1.0/tests/test_filter_widgets.py +66 -0
- pytest_django_admin_kit-1.1.0/tests/test_multiple_select.py +165 -0
- {pytest_django_admin_kit-1.0.0 → pytest_django_admin_kit-1.1.0}/tests/test_populate.py +7 -13
- pytest_django_admin_kit-1.1.0/tests/test_radio_buttons.py +164 -0
- pytest_django_admin_kit-1.1.0/tests/test_session.py +332 -0
- pytest_django_admin_kit-1.1.0/tests/test_split_date_time.py +192 -0
- {pytest_django_admin_kit-1.0.0 → pytest_django_admin_kit-1.1.0}/tests/test_submit.py +47 -1
- {pytest_django_admin_kit-1.0.0 → pytest_django_admin_kit-1.1.0}/tests/test_widgets.py +6 -4
- pytest_django_admin_kit-1.0.0/src/django_admin_kit/widgets.py +0 -172
- pytest_django_admin_kit-1.0.0/tests/test_session.py +0 -141
- {pytest_django_admin_kit-1.0.0 → pytest_django_admin_kit-1.1.0}/.gitignore +0 -0
- {pytest_django_admin_kit-1.0.0 → pytest_django_admin_kit-1.1.0}/LICENSE +0 -0
- {pytest_django_admin_kit-1.0.0 → pytest_django_admin_kit-1.1.0}/RELEASING.md +0 -0
- {pytest_django_admin_kit-1.0.0 → pytest_django_admin_kit-1.1.0}/src/django_admin_kit/__init__.py +0 -0
- {pytest_django_admin_kit-1.0.0 → pytest_django_admin_kit-1.1.0}/src/django_admin_kit/config.py +0 -0
- {pytest_django_admin_kit-1.0.0 → pytest_django_admin_kit-1.1.0}/src/django_admin_kit/errors.py +0 -0
- {pytest_django_admin_kit-1.0.0 → pytest_django_admin_kit-1.1.0}/src/django_admin_kit/matching.py +0 -0
- {pytest_django_admin_kit-1.0.0 → pytest_django_admin_kit-1.1.0}/src/django_admin_kit/messages.py +0 -0
- {pytest_django_admin_kit-1.0.0 → pytest_django_admin_kit-1.1.0}/src/django_admin_kit/normalize.py +0 -0
- {pytest_django_admin_kit-1.0.0 → pytest_django_admin_kit-1.1.0}/src/django_admin_kit/plugin.py +0 -0
- {pytest_django_admin_kit-1.0.0 → pytest_django_admin_kit-1.1.0}/src/django_admin_kit/py.typed +0 -0
- {pytest_django_admin_kit-1.0.0 → pytest_django_admin_kit-1.1.0}/src/django_admin_kit/rendered.py +0 -0
- {pytest_django_admin_kit-1.0.0 → pytest_django_admin_kit-1.1.0}/src/django_admin_kit/results.py +0 -0
- {pytest_django_admin_kit-1.0.0 → pytest_django_admin_kit-1.1.0}/src/django_admin_kit/rows.py +0 -0
- {pytest_django_admin_kit-1.0.0 → pytest_django_admin_kit-1.1.0}/src/django_admin_kit/tools.py +0 -0
- {pytest_django_admin_kit-1.0.0 → pytest_django_admin_kit-1.1.0}/src/django_admin_kit/urls.py +0 -0
- {pytest_django_admin_kit-1.0.0 → pytest_django_admin_kit-1.1.0}/tests/conftest.py +0 -0
- {pytest_django_admin_kit-1.0.0 → pytest_django_admin_kit-1.1.0}/tests/project/__init__.py +0 -0
- {pytest_django_admin_kit-1.0.0 → pytest_django_admin_kit-1.1.0}/tests/project/accounts/__init__.py +0 -0
- {pytest_django_admin_kit-1.0.0 → pytest_django_admin_kit-1.1.0}/tests/project/accounts/apps.py +0 -0
- {pytest_django_admin_kit-1.0.0 → pytest_django_admin_kit-1.1.0}/tests/project/accounts/migrations/0001_initial.py +0 -0
- {pytest_django_admin_kit-1.0.0 → pytest_django_admin_kit-1.1.0}/tests/project/accounts/migrations/__init__.py +0 -0
- {pytest_django_admin_kit-1.0.0 → pytest_django_admin_kit-1.1.0}/tests/project/accounts/models.py +0 -0
- {pytest_django_admin_kit-1.0.0 → pytest_django_admin_kit-1.1.0}/tests/project/ops.py +0 -0
- {pytest_django_admin_kit-1.0.0 → pytest_django_admin_kit-1.1.0}/tests/project/settings.py +0 -0
- {pytest_django_admin_kit-1.0.0 → pytest_django_admin_kit-1.1.0}/tests/project/shop/__init__.py +0 -0
- {pytest_django_admin_kit-1.0.0 → pytest_django_admin_kit-1.1.0}/tests/project/shop/apps.py +0 -0
- {pytest_django_admin_kit-1.0.0 → pytest_django_admin_kit-1.1.0}/tests/project/shop/kit_widgets.py +0 -0
- {pytest_django_admin_kit-1.0.0 → pytest_django_admin_kit-1.1.0}/tests/project/shop/migrations/0001_initial.py +0 -0
- {pytest_django_admin_kit-1.0.0 → pytest_django_admin_kit-1.1.0}/tests/project/shop/migrations/0002_review.py +0 -0
- {pytest_django_admin_kit-1.0.0 → pytest_django_admin_kit-1.1.0}/tests/project/shop/migrations/0003_feed_refresh.py +0 -0
- {pytest_django_admin_kit-1.0.0 → pytest_django_admin_kit-1.1.0}/tests/project/shop/migrations/0004_feed_color.py +0 -0
- {pytest_django_admin_kit-1.0.0 → pytest_django_admin_kit-1.1.0}/tests/project/shop/migrations/__init__.py +0 -0
- {pytest_django_admin_kit-1.0.0 → pytest_django_admin_kit-1.1.0}/tests/project/shop/rules.py +0 -0
- {pytest_django_admin_kit-1.0.0 → pytest_django_admin_kit-1.1.0}/tests/project/shop/templates/admin/shop/feed/submit_line.html +0 -0
- {pytest_django_admin_kit-1.0.0 → pytest_django_admin_kit-1.1.0}/tests/project/shop/templates/shop/widgets/swatch_picker.html +0 -0
- {pytest_django_admin_kit-1.0.0 → pytest_django_admin_kit-1.1.0}/tests/project/urls.py +0 -0
- {pytest_django_admin_kit-1.0.0 → pytest_django_admin_kit-1.1.0}/tests/project/urls_backoffice.py +0 -0
- {pytest_django_admin_kit-1.0.0 → pytest_django_admin_kit-1.1.0}/tests/project/urls_with_ops.py +0 -0
- {pytest_django_admin_kit-1.0.0 → pytest_django_admin_kit-1.1.0}/tests/test_actions.py +0 -0
- {pytest_django_admin_kit-1.0.0 → pytest_django_admin_kit-1.1.0}/tests/test_changelist.py +0 -0
- {pytest_django_admin_kit-1.0.0 → pytest_django_admin_kit-1.1.0}/tests/test_changelist_count.py +0 -0
- {pytest_django_admin_kit-1.0.0 → pytest_django_admin_kit-1.1.0}/tests/test_choices.py +0 -0
- {pytest_django_admin_kit-1.0.0 → pytest_django_admin_kit-1.1.0}/tests/test_config.py +0 -0
- {pytest_django_admin_kit-1.0.0 → pytest_django_admin_kit-1.1.0}/tests/test_confirm_delete.py +0 -0
- {pytest_django_admin_kit-1.0.0 → pytest_django_admin_kit-1.1.0}/tests/test_create.py +0 -0
- {pytest_django_admin_kit-1.0.0 → pytest_django_admin_kit-1.1.0}/tests/test_custom_prefix.py +0 -0
- {pytest_django_admin_kit-1.0.0 → pytest_django_admin_kit-1.1.0}/tests/test_custom_site.py +0 -0
- {pytest_django_admin_kit-1.0.0 → pytest_django_admin_kit-1.1.0}/tests/test_delete.py +0 -0
- {pytest_django_admin_kit-1.0.0 → pytest_django_admin_kit-1.1.0}/tests/test_delete_contents.py +0 -0
- {pytest_django_admin_kit-1.0.0 → pytest_django_admin_kit-1.1.0}/tests/test_edit.py +0 -0
- {pytest_django_admin_kit-1.0.0 → pytest_django_admin_kit-1.1.0}/tests/test_errors.py +0 -0
- {pytest_django_admin_kit-1.0.0 → pytest_django_admin_kit-1.1.0}/tests/test_index.py +0 -0
- {pytest_django_admin_kit-1.0.0 → pytest_django_admin_kit-1.1.0}/tests/test_interop.py +0 -0
- {pytest_django_admin_kit-1.0.0 → pytest_django_admin_kit-1.1.0}/tests/test_links.py +0 -0
- {pytest_django_admin_kit-1.0.0 → pytest_django_admin_kit-1.1.0}/tests/test_live.py +0 -0
- {pytest_django_admin_kit-1.0.0 → pytest_django_admin_kit-1.1.0}/tests/test_matching.py +0 -0
- {pytest_django_admin_kit-1.0.0 → pytest_django_admin_kit-1.1.0}/tests/test_messages.py +0 -0
- {pytest_django_admin_kit-1.0.0 → pytest_django_admin_kit-1.1.0}/tests/test_normalizers.py +0 -0
- {pytest_django_admin_kit-1.0.0 → pytest_django_admin_kit-1.1.0}/tests/test_package.py +0 -0
- {pytest_django_admin_kit-1.0.0 → pytest_django_admin_kit-1.1.0}/tests/test_pages.py +0 -0
- {pytest_django_admin_kit-1.0.0 → pytest_django_admin_kit-1.1.0}/tests/test_plugin.py +0 -0
- {pytest_django_admin_kit-1.0.0 → pytest_django_admin_kit-1.1.0}/tests/test_project.py +0 -0
- {pytest_django_admin_kit-1.0.0 → pytest_django_admin_kit-1.1.0}/tests/test_rows.py +0 -0
- {pytest_django_admin_kit-1.0.0 → pytest_django_admin_kit-1.1.0}/tests/test_urls.py +0 -0
- {pytest_django_admin_kit-1.0.0 → pytest_django_admin_kit-1.1.0}/tox.toml +0 -0
|
@@ -5,6 +5,62 @@ All notable changes to this project are listed in this file.
|
|
|
5
5
|
The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and the project uses
|
|
6
6
|
[Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
7
7
|
|
|
8
|
+
## [Unreleased]
|
|
9
|
+
|
|
10
|
+
## [1.1.0] - 2026-11-05
|
|
11
|
+
|
|
12
|
+
### Added
|
|
13
|
+
|
|
14
|
+
- Open the login page with `admin_ui.login()`, fill it with `page.fill(username=..., password=...)`
|
|
15
|
+
and submit it with `page.submit()`. A refused form is the same page, so it can be filled
|
|
16
|
+
again. Opening the login page logs nobody out.
|
|
17
|
+
- `admin_ui.login(user, password=...)` returns the result of the submission, so
|
|
18
|
+
`result.success` says whether the admin let the user in.
|
|
19
|
+
- A submission that ends on the login page has the login page as its `result.page`.
|
|
20
|
+
- Read what a login page says with `page.errors`, the notes above the form such as why a login
|
|
21
|
+
was refused, and `page.field_errors`, the errors under each input.
|
|
22
|
+
- Read the help the admin shows with a field with `field.help_text`, `""` for a field without
|
|
23
|
+
any. A field the user may only read has it too.
|
|
24
|
+
- Get the fields the user may only read and the ones with a control to fill with
|
|
25
|
+
`page.readonly_fields` and `page.editable_fields`. They follow what the page shows, so a user
|
|
26
|
+
who may only view gets every field as read-only.
|
|
27
|
+
- List the fieldsets of a form with `page.fieldsets`. Each one has its `name` and its
|
|
28
|
+
`description`, `None` when it has none, and its `fields`, read and filled like `page.fields`.
|
|
29
|
+
- Tell whether a fieldset can be collapsed and whether it is collapsed now with
|
|
30
|
+
`fieldset.collapsible` and `fieldset.collapsed`, and open or collapse it with
|
|
31
|
+
`fieldset.open()` and `fieldset.close()`.
|
|
32
|
+
- Read and fill the admin's split date-time through the built-in `SplitDateTimeWidget`. It reads
|
|
33
|
+
as the date and the time as typed, such as `("2026-01-15", "09:30:00")`, and is filled from a
|
|
34
|
+
`datetime`, a `(date, time)` pair or `None`.
|
|
35
|
+
- Read and fill radio buttons, as the admin draws a field named in `radio_fields`, through the
|
|
36
|
+
built-in `RadioWidget`. They're read, filled and listed as a select is, and read as `None`
|
|
37
|
+
while none is checked.
|
|
38
|
+
- Read and fill a multiple select, as the admin draws a many-to-many field, through the built-in
|
|
39
|
+
`SelectMultipleWidget`. It reads as the list of choices made and is filled with a list of
|
|
40
|
+
values, each written as for a select. A lone value raises `TypeError`.
|
|
41
|
+
- `page.populate(instance)` fills a many-to-many field with a saved instance's related objects,
|
|
42
|
+
and leaves it alone for an unsaved instance, which has none yet.
|
|
43
|
+
|
|
44
|
+
### Changed
|
|
45
|
+
|
|
46
|
+
- Asking `page.fields` for a field the form doesn't have now says
|
|
47
|
+
`No field named 'color' here.`, rather than `The form has no field named 'color'.`, so the
|
|
48
|
+
same message fits a fieldset's fields. It still lists the fields there are.
|
|
49
|
+
|
|
50
|
+
### Fixed
|
|
51
|
+
|
|
52
|
+
- A multiple select no longer reads as one option, and filling it no longer replaces the choice
|
|
53
|
+
with a single option. It reads and fills as a list.
|
|
54
|
+
- `page.populate(instance)` no longer fails with `ValueError` on an unsaved model instance with
|
|
55
|
+
a many-to-many field.
|
|
56
|
+
- A `filter_horizontal` or `filter_vertical` field raises `LookupError` at once when read or
|
|
57
|
+
filled, rather than waiting for the timeout. No built-in widget class takes the two lists the
|
|
58
|
+
admin's script draws for it, so a project can add its own.
|
|
59
|
+
- `admin_ui.login(user, password="")` no longer waits 30 seconds and fails. The browser does not
|
|
60
|
+
send a form with an empty input, so the result is no success, at once.
|
|
61
|
+
- Filling a field of a collapsed fieldset, with `field.fill()` or `page.populate()`, no longer
|
|
62
|
+
waits for the timeout and fails. The fieldset is opened first, as the user has to.
|
|
63
|
+
|
|
8
64
|
## [1.0.0] - 2026-10-13
|
|
9
65
|
|
|
10
66
|
The first release. Tests open the Django admin in a real browser and check it with plain
|
|
@@ -80,4 +136,6 @@ The first release. Tests open the Django admin in a real browser and check it wi
|
|
|
80
136
|
- Tests can run in parallel, and every test starts logged out.
|
|
81
137
|
- Works with Python 3.8 to 3.14 and Django 3.2 to 6.1.
|
|
82
138
|
|
|
139
|
+
[Unreleased]: https://github.com/JeyKip/pytest-django-admin-kit/compare/v1.1.0...HEAD
|
|
140
|
+
[1.1.0]: https://github.com/JeyKip/pytest-django-admin-kit/compare/v1.0.0...v1.1.0
|
|
83
141
|
[1.0.0]: https://github.com/JeyKip/pytest-django-admin-kit/releases/tag/v1.0.0
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: pytest-django-admin-kit
|
|
3
|
-
Version: 1.
|
|
4
|
-
Summary:
|
|
3
|
+
Version: 1.1.0
|
|
4
|
+
Summary: Pytest plugin for testing the Django admin in a real browser, with plain asserts.
|
|
5
5
|
Project-URL: Homepage, https://github.com/JeyKip/pytest-django-admin-kit
|
|
6
6
|
Project-URL: Source, https://github.com/JeyKip/pytest-django-admin-kit
|
|
7
7
|
Project-URL: Changelog, https://github.com/JeyKip/pytest-django-admin-kit/blob/main/CHANGELOG.md
|
|
@@ -63,7 +63,7 @@ Description-Content-Type: text/markdown
|
|
|
63
63
|
|
|
64
64
|
# pytest-django-admin-kit
|
|
65
65
|
|
|
66
|
-
|
|
66
|
+
Pytest plugin for testing the Django admin in a real browser, with plain asserts.
|
|
67
67
|
|
|
68
68
|
Tests describe admin pages, their fields, rows, permissions and errors, using ordinary `assert`
|
|
69
69
|
statements, and stay unchanged across supported Django versions.
|
|
@@ -78,10 +78,19 @@ def test_product_admin(admin_ui, admin_user, product):
|
|
|
78
78
|
|
|
79
79
|
Install it as `pytest-django-admin-kit`; import it as `django_admin_kit`.
|
|
80
80
|
|
|
81
|
+
AI agents can start from [llms.txt](https://github.com/JeyKip/pytest-django-admin-kit/blob/main/llms.txt).
|
|
82
|
+
To point yours there, add this line to your project's `AGENTS.md` or `CLAUDE.md`:
|
|
83
|
+
|
|
84
|
+
```markdown
|
|
85
|
+
Before writing admin tests, read https://raw.githubusercontent.com/JeyKip/pytest-django-admin-kit/main/llms.txt
|
|
86
|
+
```
|
|
87
|
+
|
|
81
88
|
## Status
|
|
82
89
|
|
|
83
|
-
Version 1.
|
|
84
|
-
|
|
90
|
+
Version 1.1.0 implements the API written up in
|
|
91
|
+
[specification.md](https://github.com/JeyKip/pytest-django-admin-kit/blob/main/specification.md),
|
|
92
|
+
and its public API is stable. What changed in each release is in
|
|
93
|
+
[CHANGELOG.md](https://github.com/JeyKip/pytest-django-admin-kit/blob/main/CHANGELOG.md), and what
|
|
85
94
|
comes next is the roadmap in section 33 of the specification.
|
|
86
95
|
|
|
87
96
|
## What it solves today
|
|
@@ -119,10 +128,36 @@ project that mounts its admin elsewhere changes nothing.
|
|
|
119
128
|
admin_ui.url.edit(product)
|
|
120
129
|
```
|
|
121
130
|
|
|
122
|
-
**Testing the login page itself.** Typing into the form is an opt-in, never the default.
|
|
131
|
+
**Testing the login page itself.** Typing into the form is an opt-in, never the default. The
|
|
132
|
+
result says whether the admin let the user in.
|
|
133
|
+
|
|
134
|
+
```python
|
|
135
|
+
result = admin_ui.login(user, password="secret")
|
|
136
|
+
assert result.success
|
|
137
|
+
```
|
|
138
|
+
|
|
139
|
+
To fill the form the way the test needs, open it, fill it and submit it in separate steps. A
|
|
140
|
+
refused form stays the same page, so it can be filled again.
|
|
123
141
|
|
|
124
142
|
```python
|
|
125
|
-
admin_ui.login(
|
|
143
|
+
page = admin_ui.login()
|
|
144
|
+
page.fill(username="alice", password="wrong")
|
|
145
|
+
result = page.submit()
|
|
146
|
+
assert not result.success
|
|
147
|
+
|
|
148
|
+
result.page.fill(password="secret")
|
|
149
|
+
assert result.page.submit().success
|
|
150
|
+
```
|
|
151
|
+
|
|
152
|
+
A refused page reads what the admin says: the notes above the form, and the errors under each
|
|
153
|
+
input.
|
|
154
|
+
|
|
155
|
+
```python
|
|
156
|
+
assert result.page.errors == [
|
|
157
|
+
"Please enter the correct username and password for a staff account. "
|
|
158
|
+
"Note that both fields may be case-sensitive."
|
|
159
|
+
]
|
|
160
|
+
assert result.page.field_errors == {}
|
|
126
161
|
```
|
|
127
162
|
|
|
128
163
|
**Opening a changelist and reading its count and columns.** The count is what the page says,
|
|
@@ -196,8 +231,15 @@ assert page.fields["name"].required
|
|
|
196
231
|
assert not page.fields["price_with_tax"].editable
|
|
197
232
|
assert page.required_fields == {"name", "sku", "price", "quantity", "released_on"}
|
|
198
233
|
assert page.optional_fields == {"is_active", "featured", "category"}
|
|
234
|
+
assert page.readonly_fields == {"price_with_tax"}
|
|
235
|
+
assert "name" in page.editable_fields
|
|
236
|
+
# The admin sets no `fieldsets`, so Django draws every field in one fieldset without a name.
|
|
237
|
+
assert page.fieldsets[0].name is None
|
|
238
|
+
assert list(page.fieldsets[0].fields) == list(page.fields)
|
|
239
|
+
assert not page.fieldsets[0].collapsible # no `collapse` class, so no toggle to open it
|
|
199
240
|
|
|
200
241
|
assert page.fields["name"].label == "Name"
|
|
242
|
+
assert page.fields["sku"].help_text == "The code printed on the label."
|
|
201
243
|
assert page.fields["name"].value == "Widget"
|
|
202
244
|
assert page.fields["is_active"].value is True
|
|
203
245
|
assert page.fields["category"].value == (str(tools.pk), "Tools")
|
|
@@ -233,7 +275,15 @@ assert page.fields["featured"].value == ("true", "Yes")
|
|
|
233
275
|
|
|
234
276
|
A select takes back a choice it offered, `page.fields["category"].fill(choice)`, and `None`
|
|
235
277
|
puts it on its blank option. A value no option has raises `ValueError` naming the options
|
|
236
|
-
there are, and a field the user may only read raises `LookupError`.
|
|
278
|
+
there are, and a field the user may only read raises `LookupError`. Radio buttons, as the admin
|
|
279
|
+
draws a field named in `radio_fields`, are read and filled the same way, and read as `None` while
|
|
280
|
+
none is checked. A multiple select reads as the list of choices made, and is filled with a list
|
|
281
|
+
of values, each written as for a select: `fill([tools, toys])`, and `fill([])` clears it.
|
|
282
|
+
|
|
283
|
+
A date-time, which the admin draws as a date input and a time input, reads as the two texts,
|
|
284
|
+
`("2026-01-15", "09:30:00")`. It's filled from a `datetime`, typed in the current time zone, from
|
|
285
|
+
a `(date, time)` pair whose halves may also be text or `None`, or from `None`, which empties
|
|
286
|
+
both.
|
|
237
287
|
|
|
238
288
|
**Filling a whole form in one call.** A dictionary carries the shared data and keywords carry
|
|
239
289
|
the values this one test is about. Fields that are named nowhere keep the values the admin
|
|
@@ -350,8 +400,8 @@ such as `text=`, belongs in `read` and `fill`. To combine conditions, use `:has(
|
|
|
350
400
|
`':scope:has(input[name="image_cache_key"]) input[type="file"]'`.
|
|
351
401
|
|
|
352
402
|
Your classes are tried first, in the order you list them, then the package's own:
|
|
353
|
-
`CheckboxWidget`, `
|
|
354
|
-
follow from that:
|
|
403
|
+
`CheckboxWidget`, `RadioWidget`, `SelectMultipleWidget`, `SelectWidget`, `SplitDateTimeWidget` and
|
|
404
|
+
`TextWidget`, from `django_admin_kit.widgets`. Two rules follow from that:
|
|
355
405
|
|
|
356
406
|
- A marker matches what is particular to its widget. A class marked by `input[type="file"]`
|
|
357
407
|
would take every file field in the project.
|
|
@@ -912,7 +962,7 @@ So editing `tox.toml` changes CI, and the two cannot disagree.
|
|
|
912
962
|
|
|
913
963
|
The build job refuses to run if the tag doesn't match the `version` in `pyproject.toml`, so bump
|
|
914
964
|
the version in the same commit you tag. The steps of a release are in
|
|
915
|
-
[RELEASING.md](RELEASING.md).
|
|
965
|
+
[RELEASING.md](https://github.com/JeyKip/pytest-django-admin-kit/blob/main/RELEASING.md).
|
|
916
966
|
|
|
917
967
|
## Building
|
|
918
968
|
|
|
@@ -948,4 +998,4 @@ specification.md the public API this is built against
|
|
|
948
998
|
|
|
949
999
|
## License
|
|
950
1000
|
|
|
951
|
-
MIT. See [LICENSE](LICENSE).
|
|
1001
|
+
MIT. See [LICENSE](https://github.com/JeyKip/pytest-django-admin-kit/blob/main/LICENSE).
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# pytest-django-admin-kit
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
Pytest plugin for testing the Django admin in a real browser, with plain asserts.
|
|
4
4
|
|
|
5
5
|
Tests describe admin pages, their fields, rows, permissions and errors, using ordinary `assert`
|
|
6
6
|
statements, and stay unchanged across supported Django versions.
|
|
@@ -15,10 +15,19 @@ def test_product_admin(admin_ui, admin_user, product):
|
|
|
15
15
|
|
|
16
16
|
Install it as `pytest-django-admin-kit`; import it as `django_admin_kit`.
|
|
17
17
|
|
|
18
|
+
AI agents can start from [llms.txt](https://github.com/JeyKip/pytest-django-admin-kit/blob/main/llms.txt).
|
|
19
|
+
To point yours there, add this line to your project's `AGENTS.md` or `CLAUDE.md`:
|
|
20
|
+
|
|
21
|
+
```markdown
|
|
22
|
+
Before writing admin tests, read https://raw.githubusercontent.com/JeyKip/pytest-django-admin-kit/main/llms.txt
|
|
23
|
+
```
|
|
24
|
+
|
|
18
25
|
## Status
|
|
19
26
|
|
|
20
|
-
Version 1.
|
|
21
|
-
|
|
27
|
+
Version 1.1.0 implements the API written up in
|
|
28
|
+
[specification.md](https://github.com/JeyKip/pytest-django-admin-kit/blob/main/specification.md),
|
|
29
|
+
and its public API is stable. What changed in each release is in
|
|
30
|
+
[CHANGELOG.md](https://github.com/JeyKip/pytest-django-admin-kit/blob/main/CHANGELOG.md), and what
|
|
22
31
|
comes next is the roadmap in section 33 of the specification.
|
|
23
32
|
|
|
24
33
|
## What it solves today
|
|
@@ -56,10 +65,36 @@ project that mounts its admin elsewhere changes nothing.
|
|
|
56
65
|
admin_ui.url.edit(product)
|
|
57
66
|
```
|
|
58
67
|
|
|
59
|
-
**Testing the login page itself.** Typing into the form is an opt-in, never the default.
|
|
68
|
+
**Testing the login page itself.** Typing into the form is an opt-in, never the default. The
|
|
69
|
+
result says whether the admin let the user in.
|
|
70
|
+
|
|
71
|
+
```python
|
|
72
|
+
result = admin_ui.login(user, password="secret")
|
|
73
|
+
assert result.success
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
To fill the form the way the test needs, open it, fill it and submit it in separate steps. A
|
|
77
|
+
refused form stays the same page, so it can be filled again.
|
|
60
78
|
|
|
61
79
|
```python
|
|
62
|
-
admin_ui.login(
|
|
80
|
+
page = admin_ui.login()
|
|
81
|
+
page.fill(username="alice", password="wrong")
|
|
82
|
+
result = page.submit()
|
|
83
|
+
assert not result.success
|
|
84
|
+
|
|
85
|
+
result.page.fill(password="secret")
|
|
86
|
+
assert result.page.submit().success
|
|
87
|
+
```
|
|
88
|
+
|
|
89
|
+
A refused page reads what the admin says: the notes above the form, and the errors under each
|
|
90
|
+
input.
|
|
91
|
+
|
|
92
|
+
```python
|
|
93
|
+
assert result.page.errors == [
|
|
94
|
+
"Please enter the correct username and password for a staff account. "
|
|
95
|
+
"Note that both fields may be case-sensitive."
|
|
96
|
+
]
|
|
97
|
+
assert result.page.field_errors == {}
|
|
63
98
|
```
|
|
64
99
|
|
|
65
100
|
**Opening a changelist and reading its count and columns.** The count is what the page says,
|
|
@@ -133,8 +168,15 @@ assert page.fields["name"].required
|
|
|
133
168
|
assert not page.fields["price_with_tax"].editable
|
|
134
169
|
assert page.required_fields == {"name", "sku", "price", "quantity", "released_on"}
|
|
135
170
|
assert page.optional_fields == {"is_active", "featured", "category"}
|
|
171
|
+
assert page.readonly_fields == {"price_with_tax"}
|
|
172
|
+
assert "name" in page.editable_fields
|
|
173
|
+
# The admin sets no `fieldsets`, so Django draws every field in one fieldset without a name.
|
|
174
|
+
assert page.fieldsets[0].name is None
|
|
175
|
+
assert list(page.fieldsets[0].fields) == list(page.fields)
|
|
176
|
+
assert not page.fieldsets[0].collapsible # no `collapse` class, so no toggle to open it
|
|
136
177
|
|
|
137
178
|
assert page.fields["name"].label == "Name"
|
|
179
|
+
assert page.fields["sku"].help_text == "The code printed on the label."
|
|
138
180
|
assert page.fields["name"].value == "Widget"
|
|
139
181
|
assert page.fields["is_active"].value is True
|
|
140
182
|
assert page.fields["category"].value == (str(tools.pk), "Tools")
|
|
@@ -170,7 +212,15 @@ assert page.fields["featured"].value == ("true", "Yes")
|
|
|
170
212
|
|
|
171
213
|
A select takes back a choice it offered, `page.fields["category"].fill(choice)`, and `None`
|
|
172
214
|
puts it on its blank option. A value no option has raises `ValueError` naming the options
|
|
173
|
-
there are, and a field the user may only read raises `LookupError`.
|
|
215
|
+
there are, and a field the user may only read raises `LookupError`. Radio buttons, as the admin
|
|
216
|
+
draws a field named in `radio_fields`, are read and filled the same way, and read as `None` while
|
|
217
|
+
none is checked. A multiple select reads as the list of choices made, and is filled with a list
|
|
218
|
+
of values, each written as for a select: `fill([tools, toys])`, and `fill([])` clears it.
|
|
219
|
+
|
|
220
|
+
A date-time, which the admin draws as a date input and a time input, reads as the two texts,
|
|
221
|
+
`("2026-01-15", "09:30:00")`. It's filled from a `datetime`, typed in the current time zone, from
|
|
222
|
+
a `(date, time)` pair whose halves may also be text or `None`, or from `None`, which empties
|
|
223
|
+
both.
|
|
174
224
|
|
|
175
225
|
**Filling a whole form in one call.** A dictionary carries the shared data and keywords carry
|
|
176
226
|
the values this one test is about. Fields that are named nowhere keep the values the admin
|
|
@@ -287,8 +337,8 @@ such as `text=`, belongs in `read` and `fill`. To combine conditions, use `:has(
|
|
|
287
337
|
`':scope:has(input[name="image_cache_key"]) input[type="file"]'`.
|
|
288
338
|
|
|
289
339
|
Your classes are tried first, in the order you list them, then the package's own:
|
|
290
|
-
`CheckboxWidget`, `
|
|
291
|
-
follow from that:
|
|
340
|
+
`CheckboxWidget`, `RadioWidget`, `SelectMultipleWidget`, `SelectWidget`, `SplitDateTimeWidget` and
|
|
341
|
+
`TextWidget`, from `django_admin_kit.widgets`. Two rules follow from that:
|
|
292
342
|
|
|
293
343
|
- A marker matches what is particular to its widget. A class marked by `input[type="file"]`
|
|
294
344
|
would take every file field in the project.
|
|
@@ -849,7 +899,7 @@ So editing `tox.toml` changes CI, and the two cannot disagree.
|
|
|
849
899
|
|
|
850
900
|
The build job refuses to run if the tag doesn't match the `version` in `pyproject.toml`, so bump
|
|
851
901
|
the version in the same commit you tag. The steps of a release are in
|
|
852
|
-
[RELEASING.md](RELEASING.md).
|
|
902
|
+
[RELEASING.md](https://github.com/JeyKip/pytest-django-admin-kit/blob/main/RELEASING.md).
|
|
853
903
|
|
|
854
904
|
## Building
|
|
855
905
|
|
|
@@ -885,4 +935,4 @@ specification.md the public API this is built against
|
|
|
885
935
|
|
|
886
936
|
## License
|
|
887
937
|
|
|
888
|
-
MIT. See [LICENSE](LICENSE).
|
|
938
|
+
MIT. See [LICENSE](https://github.com/JeyKip/pytest-django-admin-kit/blob/main/LICENSE).
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
# pytest-django-admin-kit
|
|
2
|
+
|
|
3
|
+
> Pytest plugin for testing the Django admin in a real browser, with plain asserts. Install it as `pytest-django-admin-kit` and import it as `django_admin_kit`. It needs Python 3.8+ and Django 3.2+.
|
|
4
|
+
|
|
5
|
+
Installing the package registers the plugin and the `admin_ui` fixture. The project also needs pytest-django configured and a browser, from `playwright install chromium`.
|
|
6
|
+
|
|
7
|
+
```python
|
|
8
|
+
def test_product_admin(admin_ui, admin_user, product):
|
|
9
|
+
admin_ui.login(admin_user)
|
|
10
|
+
|
|
11
|
+
assert admin_ui.list(Product).count == 1
|
|
12
|
+
|
|
13
|
+
page = admin_ui.edit(product)
|
|
14
|
+
page.populate(name="Renamed")
|
|
15
|
+
result = page.save()
|
|
16
|
+
|
|
17
|
+
assert result.success
|
|
18
|
+
assert result.page.messages.of_level("success")
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
Before writing tests:
|
|
22
|
+
|
|
23
|
+
- Open pages with `admin_ui.index()`, `list(Model)`, `create(Model)`, `edit(obj)`, `delete(obj)` and `open(path)`. Get URLs from `admin_ui.url` rather than hardcoding `/admin/`.
|
|
24
|
+
- `admin_ui.login(user)` needs no password. Passing `password=` types into the login form, so use it only to test that form.
|
|
25
|
+
- Reads are live. After a submission, read `result.page`, because reading a page the browser has left raises `LookupError`.
|
|
26
|
+
- Configure everything under the `DJANGO_ADMIN_KIT` setting. An unknown key is an error.
|
|
27
|
+
- A field no widget class recognizes raises `LookupError`. Add a class to `DJANGO_ADMIN_KIT["widgets"]`, or override the `admin_ui_widgets` fixture for some tests. `filter_horizontal` and `filter_vertical` fields need this today.
|
|
28
|
+
- For what the package doesn't model yet, use `.native`, the Playwright object behind a page, field, row or cell. That currently includes inlines and changelist search, filters, sorting, pagination and actions.
|
|
29
|
+
- pytest-playwright deletes `test-results/` at the start of every session, so keep other output elsewhere or pass `--output`.
|
|
30
|
+
- Don't set a fixed live server address, such as `--liveserver=localhost:8081`. It breaks parallel runs under `pytest-xdist`.
|
|
31
|
+
|
|
32
|
+
The links below follow `main`, which can be ahead of the latest release. Check a feature's version in the changelog against the version installed.
|
|
33
|
+
|
|
34
|
+
## Docs
|
|
35
|
+
|
|
36
|
+
- [README](https://raw.githubusercontent.com/JeyKip/pytest-django-admin-kit/main/README.md): "What it solves today" and "Settings" cover usage, with examples. The sections after them are for contributors.
|
|
37
|
+
- [Changelog](https://raw.githubusercontent.com/JeyKip/pytest-django-admin-kit/main/CHANGELOG.md): what each release added, and what is still unreleased.
|
|
38
|
+
|
|
39
|
+
## Optional
|
|
40
|
+
|
|
41
|
+
- [Specification](https://raw.githubusercontent.com/JeyKip/pytest-django-admin-kit/main/specification.md): the full design, over 3,000 lines. Section 27 covers fixtures, section 28 configuration and extension, and section 33 the roadmap. Parts not marked "(done)" may not exist yet.
|
|
@@ -4,8 +4,8 @@ build-backend = "hatchling.build"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "pytest-django-admin-kit"
|
|
7
|
-
version = "1.
|
|
8
|
-
description = "
|
|
7
|
+
version = "1.1.0"
|
|
8
|
+
description = "Pytest plugin for testing the Django admin in a real browser, with plain asserts."
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
requires-python = ">=3.8"
|
|
11
11
|
license = { file = "LICENSE" }
|