Browser tests¶
tests/test_browser.py drives a real Chromium, through
Playwright, against every other test file's blind
spot: JavaScript. Django's test client — what the other ~3,400 tests use — parses HTML
but never runs a line of script, so client-side-only bugs are invisible to it. Three of
them already shipped this way: the bulk-action bar staying visible at zero selection
(a CSS specificity bug, correct markup the whole time), the top bar's settings screen
linking a stylesheet and a script that did not exist (two 404s a test client cannot
see, because it never fetches anything a <script> or <link> tag points at), and
focus not moving to the error summary after a failed save (a behaviour, not a
document, so nothing short of running the script would notice).
The module needs no demo project and no server you have to remember to start:
pytest-django's live_server fixture boots a real HTTP server against the same
tests.testapp fixture models the rest of the suite uses, for the duration of each
test, and Playwright drives a browser against it. Self-contained and deterministic —
same as everything else in tests/.
Running them¶
They are not part of a plain uv run pytest and do not need to be — install
Chromium and they run in place, wherever tests/test_browser.py sits in the suite:
Without the browser group, or with it but no downloaded Chromium, the module skips
cleanly — at collection time if playwright itself is not installed, per test if it is
installed but playwright install chromium was never run. Either way, uv run pytest
(no -m browser) still collects the file and reports the skip; it never errors and
never silently vanishes from the run. A contributor who has not opted into the browser
group gets an unmodified, green suite.
CI runs them in their own job (.github/workflows/ci.yml, browser), separate from
the main test matrix so that job's runners never need Chromium at all.
What it covers¶
- Focus lands on
#v-form-summaryafter a failed save — the fix invantage.js'sfocusErrorSummarythat nothing else in the suite pins. - No console errors and no failed requests (a missing static file is a failed request, and fails the build) on the dashboard, a list, a detail page, a form, the site settings screen, the top-bar arrangement screen, a delete confirmation, and the login page.
- No element carrying
hiddencomputes to anything other thandisplay: noneon any of those same pages — the generalised form of the action-bar bug. - The relation combobox: typing filters the list, arrows highlight a row, Enter
commits it, a chip appears, and the real
<select>— never replaced, only driven — ends up holding the value. - The account menu: Enter opens it on the first item, the arrows move between items, Escape closes it and returns focus to the trigger.
- The grid's roving tabindex: exactly one element is tabbable at rest and after arrow navigation, and the column-resize handle's Left/Right exception to that contract is scoped to itself, with Escape handing focus back to the row.
- Column pinning under
dir="rtl", against a table forced to genuinely overflow its container (not asserted from CSS source, from realgetBoundingClientRect()values at several real scroll positions): the pinned column stays flush with the inline-start (right, under RTL) edge throughout.
Why they are written the way they are¶
- No arbitrary sleeps. Every wait is on a condition — an element appearing, an
attribute changing, a
page.wait_for_functionpredicate,networkidle— never a fixed delay. - A fresh Chromium per test, not once for the module.
live_serverrequires transactional tests, so the database is flushed between tests; each one creates its own user and fixture rows. Playwright's sync API leaves a running event loop on whichever thread calledchromium.launch(), which trips Django's async-safety guard for any ORM query issued afterwards on that thread — verified directly, not assumed. A session-scoped browser would poison every database-touching fixture from the second test onward. See the comment at the top oftests/test_browser.pyfor the full reasoning; the short version is that data fixtures are always a dependency of the Playwright fixtures, never requested alongside them in an order someone could reshuffle. - Cookie-based login, not the login form, for every test except the ones actually
about the login page. A session created directly through
django.contrib.sessions.backends.db.SessionStoreand handed to the browser as a cookie is faster than driving the form each time and does not make sixteen other tests brittle against the login template's markup.