These tests drive a real browser (Chromium) through the forum as each kind of user would: anonymous visitors, new and regular members, moderators (staff) and administrators (superusers), on a desktop screen and on a phone in dark mode. They run against Django's live test server: a fresh test database, the real middleware, templates, CSS and JavaScript, nothing mocked except outgoing email, which is captured.
Every page a test opens is watched. A test fails when a page:
- answers with a server error (5xx);
- throws a JavaScript error;
- logs a console error, including content blocked by the Content-Security-Policy;
- scrolls sideways on the phone viewport. The failure names the elements that stick out.
Two kinds of test:
- Workflow tests do what people do (ask, answer, moderate, configure) and check the result in the database and on the page.
- The crawler (
test_crawl.py) follows every link each kind of user can see, from the home page, their profile and the admin pages. It checks every page it reaches, once per kind of page, and reports all failures together, each with the page that linked to it.
pip install -r requirements-dev.txt
python -m playwright install chromium # once; add --with-deps on a fresh Linux machine
OSQA_SETTINGS_LOCAL=settings_local_e2e python manage.py test e2eThe suite takes about five minutes. Run part of it by naming the module, class or test:
OSQA_SETTINGS_LOCAL=settings_local_e2e python manage.py test e2e.test_posting
OSQA_SETTINGS_LOCAL=settings_local_e2e python manage.py test e2e.test_admin.AdministrationTest.test_maintenance_modesettings_local_e2e.py uses the unit test settings with a file-based SQLite
test database (tmp/test_e2e.db). The live server answers the browser from
several threads, which can't share an in-memory database.
Options (environment variables):
| Variable | Effect |
|---|---|
E2E_HEADED=1 |
Show the browser window |
E2E_SLOWMO=300 |
Wait 300 ms between browser actions, to follow along |
E2E_TIMEOUT=20000 |
Per-action timeout in ms (default 10000) |
E2E_CHROMIUM_PATH=/path/to/chrome |
Use this Chromium instead of Playwright's download |
E2E_ARTIFACTS=dir |
Where screenshots of failed tests go (default tmp/e2e/) |
E2E_DB_NAME=name.db |
Test database file in tmp/, so several runs can work side by side |
E2E_RECORD=file |
Append every forum URL the browser requests to this file (used by coverage.py) |
E2E_CSS_COVERAGE=1 |
Record which style sheet rules each page uses, in tmp/e2e/css-usage.jsonl (used by css_coverage.py) |
E2E_UPDATE_BASELINES=1 |
Visual tests: write the screenshots as the new baselines instead of comparing |
E2E_VISUAL_STRICT=1 |
Visual tests: compare even when the baselines were made on another environment |
When a test fails, a full-page screenshot of every page it had open is saved
in tmp/e2e/, named after the test.
| Module | Who | What |
|---|---|---|
test_public.py |
anonymous | Every public page on desktop and on a phone in dark mode; question content; voting asks to sign in; search; tag pages; Markdown/JSONL/llms.txt/RSS/sitemap/health endpoints; 404 page; language switcher |
test_search.py |
anonymous | The header search box; results ranked (title matches first) with marked snippets; sort tabs; a [tag] operator; search tips; the empty state (phone, dark) with "without the filters" and "Ask a question" (title prefilled) |
test_searchbox.py |
anonymous, members | The header search box: "/" focuses it (not while typing elsewhere); live suggestions (questions, tags, people) from /search/suggest/ as a combobox: arrow keys, Enter opens a question, Escape closes then leaves; the "Search for" row runs the full search; clicking a tag or a person; the clear button; full-width panel with large rows on a phone, light and dark (screenshots in tmp/e2e/searchbox-*.png) |
test_theme_and_shortcuts.py |
anonymous, members, administrators | The theme switch: System follows the emulated colour scheme (also when it changes); Light/Dark from the footer and the header menu apply without a reload, are right at the first paint after a reload (data-theme and background at DOMContentLoaded), persist by cookie and in a member's preferences (a fresh browser too); the admin follows; highlighted code and charts take the theme's colours. Keyboard shortcuts: the ? dialog (axe, focus kept inside, Escape), /, g + letter, a, j/k/Enter on lists, j/k/u on a question page, nothing while typing or with modifiers, turning them off and on in the preferences |
test_accounts.py |
new and regular users | Sign-up (with the password rules), sign-out, sign-in; sign-in lockout after wrong passwords; forgotten password by email link; following a tag; email settings (master switch, checkboxes); unsubscribe link from an email, with undo; editing the profile; account pages on a phone; data download and account deletion |
test_signin_pages.py |
anonymous | Sign-in, sign-up and forgotten-password pages on a desktop and a phone in dark mode (screenshots in tmp/e2e/signin-pages-*.png); show-password button; error summary and inline errors on a bad sign-up, values kept; links between the pages; signing in with an email address; Google/GitHub as labelled buttons when configured |
test_posting.py |
askers, answerers, commenters | Asking with live preview; answering; commenting with an @mention (and the mention suggestions); voting; notifications bell and list; accepting an answer; favourites; following/unfollowing a question; editing a question; reporting a post; answering on a phone |
test_question_page.py |
askers, members, moderators | The question page: Copy button on code blocks (clipboard, keyboard, "Copied" announced); accepted answer badge and "Jump to accepted answer"; the asker's accept toggle; answer sort tabs; <time> elements with exact dates; edited link to revisions; the post menu from the keyboard and as a sheet on a phone; vote pressed state; the comment form and its counter; a question closed as a duplicate (screenshots in tmp/e2e/question-page-*.png) |
test_ask_page.py |
askers, editors | The ask and edit pages: tags as chips (Enter/comma/space, Backspace, remove buttons, suggestions, the submitted value); "Similar questions" with accepted/answered markers; Write/Preview tabs on a phone and the preview below the editor on a desktop; the draft restored and discarded; Ctrl+Enter and the leave warning (not after posting); the error summary with values kept; editing someone else's question (note, revision picker, edit summary) and one's own answer (screenshots in tmp/e2e/ask-page-*.png) |
test_editing.py |
members | Ask form validation; image upload in the editor; editing an answer; deleting a comment |
test_editor.py |
members | The post editor: toolbar buttons and pressed states, roving tab stop and tooltips, shortcuts and undo/redo, code block languages, tables, Tab/Shift+Tab and Escape-then-Tab, dropped and pasted images (placeholders, several at once, refused files), HTML from web pages/Google Docs/Word converted to Markdown, plain paste, @mentions, count and minimum length, the answer form and edit page, a phone in dark mode, axe on the toolbar, menu and dialogs |
test_emails.py |
members, administrators | Every email a member can get, checked for recipients, content and unsubscribe links: new comment and @mention, answer accepted (only to those who asked for it), new member, new question, daily digest; turning each preference off stops it; unsubscribe and undo for every kind; auto-following viewed or all questions; the "questions you follow" tab; welcome email validation link; changing an email address and validating the new one |
test_rules.py |
anonymous, newcomers, members, moderators | The rules behind the buttons, with the message shown and the score/reputation in the database: who may vote (sign-in prompt, reputation per direction), down votes and taking votes back, daily votes and the "votes left" warning, old votes; accepting and unaccepting; reporting (refusals, daily limit, the hide/delete counts); comment length, reputation and edit window; a moderator deleting a comment; award points refusals; retagging without edit rights |
test_content.py |
members, moderators | Revisions and rollback; permanent answer links; awarding points; liking and editing comments; community wiki (phone); converting comments, answers and questions; question and tag suggestions on the ask page; interesting/ignored tags; tag descriptions; posts written before signing in, published or discarded |
test_people.py |
members, administrators, agents | Profile tabs (votes, favourites, reputation, activity, preferences); reporting a user; awarding points (phone); badges; contact form; Markdown help; OpenSearch; llms.txt and the links in it; the MCP server; the notification bell's count and "mark all read"; email validation link (phone); the AI assistant and AI suggestions with a fake Gemini |
test_admin_tools.py |
administrators | Custom pages; statistics (phone); recalculating counts; bootstrap mode and defaults; resetting a setting; testing email settings; acting on moderation reports (phone); XML backup and download; the StackExchange importer; sign-up with GitHub, then linking and unlinking Google (provider calls mocked) |
test_accessibility.py |
anonymous, members, administrators | WCAG 2.0/2.1 A and AA (axe-core, vendored in e2e/vendor/) on the main pages, the user and post menus, the header search suggestions, the admin dashboard, settings, support, statistics, custom page, email preview, create user and node manager pages, the admin menu open on a phone, and the moderation inbox (with the spam dialog open), on a desktop and on a phone in dark mode. A failure lists each rule, its impact and the offending elements |
test_wcag22.py |
anonymous, members, staff | WCAG 2.2 AA checks axe can't make, measured on the main and newer pages (desktop and phone, light and dark): 24px target size or spacing, Tab through each page with the focused element never entirely covered (also with the live banner and the phone message bar, and scrolled to their edge) and a focus ring of 3:1, reflow at 320 and 640px, the text spacing of 1.4.12 without clipped text, Escape and focus return of the editor dialogs and menu, mention list, shortcuts, theme menu, search filters and spam dialogs, Show in the live banner moving the focus, editor tooltips that stay on hover, keyboard choices that don't reload the page, no dragging needed, sign-in fields that allow paste and autofill (one-time-code), what was typed kept after an error, help links in the same order, no motion with reduced motion, axe with the WCAG 2.2 rules and one h1 per page |
test_offline.py |
anonymous, members | Installable app: manifest, icons and service worker; a saved question read offline with the offline banner (desktop, phone in dark mode, axe-checked); the offline page listing saved pages; sign-out forgets them |
test_moderation.py |
members, moderators, administrators | Suspending for a day with public and private messages (shown escaped), signing in while suspended, withdrawing the suspension, a suspension that has ended, an indefinite one; closing with a reason (and an empty one refused), owners closing and reopening their own question; deleting and undeleting; bulk close/delete/undelete in Admin > Tools; granting and revoking moderator rights; converting a comment, and one's own answer, into a question; @mentions in a question and in an edit on the bell; the contact form for members; badge pages; the profile avatar (phone, dark) |
test_api.py |
members, anonymous, programs | The API tokens page: create a token, copy it once (clipboard), use it, revoke it (then 401); a read-only token on a phone in dark mode (refused writes); the API docs from the footer on desktop and phone (dark); every /api/v1/ endpoint called with fetch() from a page, reads and writes |
test_crawl.py |
every role | Every link reachable by anonymous visitors (desktop and phone), newcomers, members (phone), experts, moderators and administrators (desktop and phone) |
test_ui_robustness.py |
anonymous, members, moderators | Reflow at 320px and at 1280px zoomed to 200% (no sideways scrolling, the main button on screen and not covered); tablet (768px) and wide (1920px) layouts (header parts don't overlap, sidebar beside or below the content); long content (200-character title without spaces, 60-character username, ten long tags, a huge table, an unbroken URL, long comments) stays inside its column on desktop and phone; keyboard only ("Skip to content" first, then the search box, the navigation and Ask, focus always visible; the close/delete/report dialogs keep focus inside, close on Escape and give it back); prefers-reduced-motion (no animation or transition, jQuery effects instant); the print style sheet of a question page |
test_visual.py |
anonymous, members, administrators | Visual regression: screenshots compared with the baselines in visual/baselines/ (see below) |
test_web_vitals.py |
anonymous, members | Core Web Vitals of the main pages (home, lists, a question with images and code, tag, profile, search, ask, sign-in) loaded cold on a 390px phone over slow 4G with the CPU slowed 4x: CLS < 0.05, LCP < 2.5 s, TTFB, INP (a click) and request/byte ceilings per page (web_vitals.py; E2E_VITALS_REPORT=file appends the table of figures, E2E_VITALS_DEBUG=1 lists each page's requests) |
test_review_queue.py |
newcomers, members, moderators | First posts review: a newcomer's answer held in "before" mode (notice, "Waiting for review" badge), hidden from other members and visitors, approved from the moderation inbox on a phone (the asker's email, the author's notice); a held question in lists and its 404 for others; "Delete as spam" with its dialog (cancel, suspend, other posts deleted) and undo; the header count, the inbox filters, j / k between cards, dismissing a flag; the old Flagged posts URL |
test_admin.py |
moderators, administrators, and ordinary users | Closing as a duplicate, reopening, deleting; flagged posts and the support dashboard; ordinary users and moderators kept out of what isn't theirs; every settings page loads and saves; changing a setting; the admin pages (header links, menu beside the page with the current page marked, folded above it on a phone), the old layout switch URL redirecting to the dashboard, the moderators' menu; creating users; granting moderator rights; suspending; maintenance mode (closed to visitors, open to the allowed address); Django's model admin |
test_visual.py screenshots key pages and compares them with the PNGs in
e2e/visual/baselines/: home, question page (accepted answer, code,
comments), ask page, sign-in, sign-up with errors, search results with the
suggestions open, a profile, the tags index and the admin dashboard, each on a
desktop in light mode and on a phone in dark mode, plus a desktop in dark mode
for the home, question and sign-in pages. A screenshot fails when more than
0.2% of its pixels differ by more than 24 (of 255) in a colour channel; the
failure writes <name>-actual.png, -expected.png and -diff.png (differing
pixels in red) to tmp/e2e/visual/, which CI uploads with the other
screenshots.
The pages are made deterministic: fixed users and posts; every date in the
database moved to fixed times in March 2021 and "now" frozen for the relative
dates; question views not counted; animations, transitions, the caret and
jQuery effects off; Gravatar images replaced by a local grey square (every
other outside request is refused); screenshots wait for the network, fonts
and animations and stop at 1500px (desktop) or 2200px (phone, at device
scale 1 to keep the files small). Use mask= in snapshot() for anything
that can't be fixed.
Updating the baselines. After an intended visual change (a redesign, a new element on one of these pages), regenerate them and review the new images before committing:
E2E_UPDATE_BASELINES=1 OSQA_SETTINGS_LOCAL=settings_local_e2e python manage.py test e2e.test_visual
git diff --stat e2e/visual/baselines/ # look at each changed imageA screenshot without a baseline is skipped with a message, not failed. The
images depend on the machine that renders them (the fonts behind
system-ui, the Chromium version): visual/baselines/environment.json
records the environment that made them, and on a different one the
comparisons are skipped with a message (set E2E_VISUAL_STRICT=1 to compare
anyway). To have CI compare them, generate the baselines in the same
environment as CI (e.g. a run of the CI job with E2E_UPDATE_BASELINES=1
that uploads e2e/visual/baselines/ as an artifact) and commit those.
OSQA_SETTINGS_LOCAL=settings_local_e2e python e2e/coverage.py # runs the suite, then reports
OSQA_SETTINGS_LOCAL=settings_local_e2e python e2e/coverage.py --report # report on the last runThe report resolves every page, form and AJAX request the browser made to
the forum's URL patterns and lists those no test reached. Static files and
Django's model admin are left out. Requests made with page.request aren't
browser requests and don't count: reach an endpoint by clicking, by
page.goto, or with fetch inside the page. When you add a page or a
command, add a test that reaches it, and check the report.
The public pages load one style sheet, forum/skins/default/media/style/osqa.css
(sections in cascade order: legacy base, modern layout and colour variables,
profile, ask, lists, search box, accessibility and print, editor, profile
menu, Markdown help), plus jQuery UI's; the sign-in pages add auth.css; the
admin pages (osqaadmin/base.html) use Django admin's style sheets plus admin.css.
OSQA_SETTINGS_LOCAL=settings_local_e2e python e2e/css_coverage.py # runs the suite, then reports
OSQA_SETTINGS_LOCAL=settings_local_e2e python e2e/css_coverage.py e2e.test_crawl # only some tests
OSQA_SETTINGS_LOCAL=settings_local_e2e python e2e/css_coverage.py --report # report on the last run
OSQA_SETTINGS_LOCAL=settings_local_e2e python e2e/css_coverage.py --report --prune # remove dead rules
OSQA_SETTINGS_LOCAL=settings_local_e2e python e2e/css_coverage.py --report --overridden --file=default/media/style/osqa.cssWith E2E_CSS_COVERAGE=1 every page a test opens (all roles, desktop and
phone, light and dark, with the menus, dialogs, suggestions and errors the
tests bring up) is watched: Chromium's CSS coverage (CDP rule usage, taken at
each load and before each navigation) and a script that tests every selector,
with :hover/:focus-like states and pseudo-elements stripped, against the
document after each DOM change. The report lists per style sheet the rules
used, those only referenced, and the dead selectors: not used on any page
and naming a class or id that appears nowhere in the templates, JavaScript
(vendored libraries included) or Python of forum/ and forum_modules/.
Selectors made only of elements, @media print and names built from a
prefix ('vote-' + kind) are kept. --prune removes dead selectors, rules
left empty and declarations no browser reads any more (IE hacks,
-moz-border-radius, a legacy prefix followed by its standard form);
--overridden lists (with --prune, removes) declarations that a later rule
with the same selector and @media context always replaces. A file changed
since the recorded run isn't judged (its offsets no longer fit). After
pruning, review the diff and run e2e.test_visual.
The unit tests (forum/tests/, run with settings_local_test) cover the
server side in more depth. These browser tests check that the pieces work
together in a real browser.
from .base import E2ETestCase
class MyTest(E2ETestCase):
def test_something(self):
alice = self.create_user('alice', reputation=100) # also: is_staff=True, is_superuser=True
question = self.create_question(alice, 'A question title', tags='collect')
page = self.open(alice, device='mobile', dark=True) # signed in; open() alone is anonymous
self.visit(page, question.get_absolute_url()) # loads and checks the page
self.click_command(page, '#post-%d-upvote' % question.id) # AJAX command, then checks the page
page.click('#node-%d-menu-trigger' % question.id)
page.click('a[href^="/flag/"]')
self.confirm_dialog(page, {'textarea[name=prompt]': 'spam'}) # commands that ask first
self.check_page(page) # after anything else you doOther helpers:
create_answer,create_comment: these go through the forum's actions, so notifications, emails and badges happen.self.sent_mail/mail_to(user): the captured emails.set_setting(settings.X, value): change a site setting for one test.wait_for_ajax(page): wait for AJAX requests to finish.settle(page): wait for AJAX, and for a page reload if the command triggered one.
Problems that a test expects, such as the 404 of a missing page, are
cleared with page.problems.clear(). Problems left unchecked fail the test
when it ends.