A failing test that nobody can read is a test that goes ignored. Visual regressions need pixel diffs the reviewer can scan; accessibility violations need a structured report with rule ids and affected elements. This last lesson of the chapter wires the two together — saving structured a11y JSON to disk, generating browsable HTML dashboards with pytest-html, attaching screenshots and reports to Allure, and combining visual and a11y checks in one test so a single PR check covers both. By the end you'll have the reporting infrastructure that lets a non-engineer (PM, designer, accessibility specialist) review the suite's output and act on it.
Saving a11y reports to disk
The structured output of axe.run(page) lives on results.response. Dump it to JSON for review:
import json
from pathlib import Path
from playwright.sync_api import Page
def test_a11y_audit_homepage(page: Page, axe_scanner):
page.goto("/")
results = axe_scanner.run(page)
report_dir = Path("reports/a11y")
report_dir.mkdir(parents=True, exist_ok=True)
with open(report_dir / "homepage.json", "w") as f:
json.dump(results.response, f, indent=2)
high_impact = [v for v in results.violations if v["impact"] in ["critical", "serious"]]
assert len(high_impact) == 0Each scanned page produces a JSON file with the full axe output — every violation, every passing rule, every node, every help URL. The team can grep, diff, and trend these files over time. CI uploads reports/a11y/ as an artefact; reviewers download and inspect on demand.
Multi-page accessibility dashboard via parametrize
Combine the reporting pattern with parametrize to scan an entire site in one test function:
@pytest.mark.a11y
@pytest.mark.parametrize("path", ["/", "/products", "/login", "/checkout", "/about"])
def test_a11y_per_page(page: Page, axe_scanner, path: str):
page.goto(path)
results = axe_scanner.run(page)
# Persist the report regardless of pass/fail
safe_name = path.replace("/", "_") or "_home"
Path("reports/a11y").mkdir(parents=True, exist_ok=True)
with open(f"reports/a11y/{safe_name}.json", "w") as f:
json.dump(results.response, f, indent=2)
critical = [v for v in results.violations if v["impact"] in ["critical", "serious"]]
assert len(critical) == 0, (
f"Critical/serious a11y issues on {path}:\n"
+ "\n".join(f" [{v['impact']}] {v['id']}: {v['description']}" for v in critical)
)One test function runs five times, produces five JSON reports, and fails per-page with a per-page assertion message. Adding a sixth route is one line in the parametrize list.
pytest-html — the simple HTML dashboard
pytest-html turns any pytest run into a browsable HTML report. Install and use:
pip install pytest-html
pytest --html=reports/index.html --self-contained-html--self-contained-html inlines all CSS and images so the report is a single file you can email or upload. The report shows every test, its duration, its outcome, captured stdout/stderr, and any attached files.
Attach screenshots from a test:
import pytest
@pytest.fixture(autouse=True)
def attach_screenshot_on_failure(request, page):
yield
if request.node.rep_call.failed:
screenshot_bytes = page.screenshot()
# Attach via pytest-html's extra mechanism
if hasattr(request.config, "_html"):
from pytest_html import extras
request.node.user_properties.append(("screenshot", screenshot_bytes))When a test fails, the pytest-html report shows the screenshot alongside the failure message. Reviewers see the broken UI without re-running locally.
Allure — the production-grade reporting tool
Allure is heavier than pytest-html but produces a much richer dashboard — feature/severity hierarchies, trend graphs, attachment galleries, history tracking. Install:
pip install allure-pytestRun with the Allure adapter:
pytest --alluredir=reports/allure
allure serve reports/allureThe first command writes JSON results; the second launches a local web server with the rendered dashboard. CI typically uploads reports/allure/ as a build artefact and a downstream job publishes the HTML.
Allure attachments — screenshots and structured data
Allure's killer feature is attachments — anything you call allure.attach(...) on appears under the relevant test in the dashboard:
import allure
import json
from playwright.sync_api import Page
@allure.feature("Visual Testing")
def test_homepage_visual(page: Page):
page.goto("/")
screenshot = page.screenshot()
allure.attach(
screenshot,
name="Homepage",
attachment_type=allure.attachment_type.PNG,
)
expect(page).to_have_screenshot()The @allure.feature decorator groups the test under a "Visual Testing" feature in the dashboard. The allure.attach(...) call embeds the PNG into the test's report card — clickable, zoomable, downloadable.
For accessibility, attach the JSON results directly:
@allure.feature("Accessibility")
def test_a11y_homepage(page: Page, axe_scanner):
page.goto("/")
results = axe_scanner.run(page)
allure.attach(
json.dumps(results.response, indent=2),
name="Accessibility Report",
attachment_type=allure.attachment_type.JSON,
)
high_impact = [v for v in results.violations if v["impact"] in ["critical", "serious"]]
assert len(high_impact) == 0Now every a11y test in the report has a clickable JSON viewer with the full axe response, plus a structured breakdown of violations.
Combining visual and a11y in one test
The two scans are cheap and complementary — pair them in tests that care about both:
import allure
import json
from playwright.sync_api import Page, expect
@allure.feature("Page Quality")
def test_products_page_quality(page: Page, axe_scanner):
page.goto("/products")
# Visual check — captures or compares a snapshot
expect(page).to_have_screenshot(
"products.png",
animations="disabled",
max_diff_pixel_ratio=0.01,
)
# Accessibility check — asserts no high-impact violations
results = axe_scanner.run(page)
allure.attach(
json.dumps(results.response, indent=2),
name="A11y results",
attachment_type=allure.attachment_type.JSON,
)
high_impact = [v for v in results.violations if v["impact"] in ["critical", "serious"]]
assert len(high_impact) == 0One test, two assertions, both surfaces covered. If either fails, you know which one and why — pixel diff for visuals, structured JSON for a11y. The Allure dashboard groups them under "Page Quality" so PMs and designers see them together.
The reporting pipeline
Five steps, three artefact types (visual diffs, a11y JSON, run report), and the result is a self-service dashboard the whole team can use without re-running tests locally.
Per-test report metadata with Allure decorators
Allure exposes a rich set of decorators for organising the dashboard:
import allure
@allure.feature("Authentication")
@allure.story("Login")
@allure.severity(allure.severity_level.CRITICAL)
def test_admin_login(page, axe_scanner):
allure.dynamic.title("Admin can log in and reach dashboard")
page.goto("/login")
# ...@allure.feature— the top-level grouping (use for large product areas).@allure.story— sub-grouping under a feature (use for scenarios within an area).@allure.severity—BLOCKER/CRITICAL/NORMAL/MINOR/TRIVIAL. Drives report sorting and filtering.allure.dynamic.title(...)— runtime override of the test name. Useful for parametrized tests where you want a custom display name per case.
The dashboard surfaces these as filters and breakdowns — "show all critical Auth tests that failed in the last 5 runs" is a one-click query.
Coming from Playwright TypeScript?
The TS course covers this material with HTML reporter and Allure as well — the difference is install commands and import statements:
- TS
npx playwright test --reporter=html→ Pythonpytest --html=reports/index.html - TS
npx playwright test --reporter=allure-playwright→ Pythonpytest --alluredir=reports/allure - TS
await testInfo.attach('name', { body, contentType })→ Pythonallure.attach(body, name="name", attachment_type=...)
The conceptual model is identical — capture artefacts during the test, render to a dashboard after the run, publish the dashboard from CI.
Cross-reference: the Manual Software Testing course
The Manual Software Testing course covers the human side of accessibility — screen-reader testing, keyboard-only navigation, the WCAG criteria automated tools can't check. Use that course's content to build the audit checklist; use this lesson's automation to gate the easy half. Together they cover ~80% of the WCAG surface; the last 20% always requires manual review.
⚠️ Common mistakes
- Treating Allure as a build artefact upload only. Allure shines when its history feature is configured —
--allure-features-history-style flags let one run see the trend of the previous N runs. Without history, every run looks the same; with it, you see flake patterns and regressions across time. Set up history persistence in CI on day one. - Attaching huge artefacts to every test. A 5MB full-page screenshot on every test inflates report sizes into hundreds of MB. Attach screenshots only on failure (via an autouse fixture that checks the test outcome), or use Allure's
stepmechanism to attach intermediate evidence only when relevant. - Skipping the
Path.mkdir(parents=True, exist_ok=True)step before writing reports. The first time you run on a fresh CI runner, the reports directory doesn't exist andopen(..., "w")raisesFileNotFoundError. Always create the parent directory before writing —parents=Truehandles nested paths,exist_ok=Truemakes it idempotent.
🎯 Practice task
Wire up reporting end-to-end. 30-40 minutes.
-
Install reporting tools:
pip install pytest-html allure-pytest -
Add a "page quality" test that combines visual + a11y + Allure attachments. In
tests/test_quality.py:import json import allure import pytest from playwright.sync_api import Page, expect from axe_playwright_python.sync_playwright import Axe @pytest.fixture def axe_scanner(): return Axe() @allure.feature("Page Quality") @pytest.mark.parametrize("path,name", [ ("/", "login"), ("/inventory.html", "inventory"), ]) def test_page_quality(page: Page, axe_scanner, path, name, standard_user_state, browser): # Use auth state for paths that need it; reuse from chapter 5 if path != "/": context = browser.new_context( storage_state=standard_user_state, base_url="https://www.saucedemo.com", ) page = context.new_page() page.goto(f"https://www.saucedemo.com{path}") # Visual expect(page).to_have_screenshot(f"{name}.png", animations="disabled") # A11y results = axe_scanner.run(page) allure.attach( json.dumps(results.response, indent=2), name=f"A11y for {name}", attachment_type=allure.attachment_type.JSON, ) high_impact = [v for v in results.violations if v["impact"] in ["critical", "serious"]] assert len(high_impact) == 0 -
Generate visual baselines:
pytest tests/test_quality.py --update-snapshots. -
Run with both reporters:
pytest tests/test_quality.py \ --html=reports/visual.html --self-contained-html \ --alluredir=reports/allureOpen
reports/visual.htmlto see the pytest-html dashboard. -
Generate the Allure report:
allure serve reports/allure. A browser opens with the Allure dashboard. Click into a test — the JSON a11y attachment appears in the test detail. -
Force a failure. Modify a baseline (capture a fresh one, then alter its visual content via custom CSS or by loading a different
--browser). Re-run. Both the HTML and Allure reports show the failure with the diff and a11y attachments still attached. -
Stretch: wire it into a
.github/workflows/test.yml(or your CI config). The job runspytest, uploadsreports/as an artefact, and prints the artefact URL in the job summary. Reviewers downloadreports/visual.htmlfrom the action run page; for Allure, set upactions/upload-artifactforreports/allureand a follow-uppeaceiris/actions-gh-pagesstep to publish to GitHub Pages.
You've completed the visual testing and accessibility chapter. The next chapter takes the suite into CI/CD territory — running on GitHub Actions, parallelising with pytest-xdist, Docker images, and the Allure/HTML reporting integration that publishes results to the team automatically.