You've got to_have_screenshot() working. Now the question is what to capture — the whole scrollable page, just the viewport, just one component, or all three at every breakpoint. The answer shapes the whole maintenance profile of your visual suite: full-page snapshots catch broad layout regressions but flake on every minor change; component-level snapshots catch focused regressions but miss cross-component layout shifts; multi-viewport snapshots catch responsive regressions but multiply the baseline count by the number of breakpoints. This lesson covers the trade-offs, the responsive testing pattern, the cross-browser reality, and the strategy that production teams actually use.
Full-page screenshots — the broad sweep
expect(page).to_have_screenshot("homepage-full.png", full_page=True)full_page=True stitches the entire scrollable document height into one image. For a 5000px-tall landing page, you get a 5000px snapshot. Diffs surface anywhere on the page — layout collapse, footer disappearance, ad slot resize.
When to use:
- Smoke-style visual sanity — one snapshot per major page, captures the worst-case "everything below the fold broke" regression.
- Marketing/landing pages where the whole page is hand-designed and any change is meaningful.
- Pre-release sign-off when you want an at-a-glance look at every key page.
When not to use:
- App-style pages with live data. A dashboard with five charts and three timestamps will diff on every run regardless of code changes.
- Pages with infinite scroll or dynamic loading — the screenshot height varies per load.
Element screenshots — focused capture
header = page.get_by_role("banner")
expect(header).to_have_screenshot("header.png")
footer = page.get_by_role("contentinfo")
expect(footer).to_have_screenshot("footer.png")
product_card = page.get_by_test_id("product-card").first
expect(product_card).to_have_screenshot("product-card.png")Element screenshots capture only the bounding box of the matched locator. The rest of the page is invisible to the diff. This is the right pattern for component visual regression — the header changes? Header diff fires. The footer? Different diff fires. Independent baselines, independent failure modes.
When to use:
- Component libraries — the design system page that documents your buttons, inputs, cards.
- Repeated widgets — a single product card, a single notification, a single toast.
- Static regions of dynamic pages — capture the navigation header even on a dashboard whose body is unstable.
The core idea: capture the smallest stable region that conveys the regression you care about. A failing header test should mean the header changed, not "something somewhere in the page changed and we don't know what."
Responsive visual testing — viewports as parameters
Combine parametrize (chapter 3) with viewport changes:
import pytest
from playwright.sync_api import Page, expect
@pytest.mark.parametrize("width,height,name", [
(375, 667, "mobile"),
(768, 1024, "tablet"),
(1280, 800, "laptop"),
(1920, 1080, "desktop"),
])
def test_homepage_responsive(page: Page, width, height, name):
page.set_viewport_size({"width": width, "height": height})
page.goto("/")
expect(page).to_have_screenshot(f"homepage-{name}.png", animations="disabled")One test function, four viewport runs, four named baselines (homepage-mobile.png, etc.). When the design team adds a new responsive breakpoint, you add one tuple to the list — no copy-paste of the test body.
The shape of a responsive visual suite:
- One test per page, parametrized over the breakpoints the design system supports.
- Element snapshots for components — each component gets its own visual test, parametrized over the same breakpoints.
- Skip combinations that don't make sense — e.g., a sticky-mobile-nav component doesn't need a desktop baseline.
Cross-browser visual baselines
Playwright stores baselines per browser project automatically:
tests/
└── __snapshots__/
├── test_homepage_responsive_chromium/
│ ├── homepage-mobile.png
│ ├── homepage-tablet.png
│ └── homepage-desktop.png
├── test_homepage_responsive_firefox/
│ ├── homepage-mobile.png
│ └── ...
└── test_homepage_responsive_webkit/
├── homepage-mobile.png
└── ...
Three browsers × four viewports = 12 baselines per test. The math compounds quickly. Two practical responses:
- Limit the matrix. Run cross-browser visuals on a subset of pages (login, checkout, key landing pages) and run multi-viewport visuals on a subset of browsers (Chromium-only for responsive sweeps).
- Capture in CI, not locally. Different OSes render fonts differently — a baseline from your macOS laptop fails on the team's Ubuntu CI. Generate every baseline on the CI runner and commit the artefacts.
Anti-aliasing and font rendering
Even on the same machine, the same browser, the same code, screenshots can differ by a few pixels run-to-run because of:
- Sub-pixel anti-aliasing on text edges
- GPU vs software rendering differences
- Tiny floating-point variances in transform matrices
The pragmatic answer: don't pursue exact-pixel matches.
expect(page).to_have_screenshot("page.png", max_diff_pixel_ratio=0.01)max_diff_pixel_ratio=0.01 (1% tolerance) is loose enough to absorb anti-aliasing noise and tight enough to fail on real changes. Tune per-test if needed — a high-density component might need 0.005, a sparse layout might tolerate 0.02. Most teams find a single suite-wide value works fine.
Visual testing strategy — what to capture, how often
The decision flow most teams settle on: visual-test only the surfaces where visual regressions are likely and meaningful. A 1000-test functional suite with 30 carefully chosen visual tests catches visual regressions without the maintenance overhead of 1000 visual baselines.
A complete visual-testing strategy for a typical app
A reasonable starting point:
import pytest
from playwright.sync_api import Page, expect
# 1. Smoke — full-page on the three or four most important pages
@pytest.mark.visual
@pytest.mark.parametrize("path", ["/", "/products", "/about", "/contact"])
def test_landing_pages_visual(page: Page, path: str):
page.goto(path)
expect(page).to_have_screenshot(
f"page{path.replace('/', '_') or '_home'}.png",
full_page=True,
animations="disabled",
max_diff_pixel_ratio=0.01,
)
# 2. Components — element-level for the design system
@pytest.mark.visual
@pytest.mark.parametrize("variant", ["primary", "secondary", "danger"])
def test_button_variants(page: Page, variant: str):
page.goto("/design-system/buttons")
btn = page.get_by_test_id(f"button-{variant}")
expect(btn).to_have_screenshot(f"button-{variant}.png")
# 3. Responsive — parametrize a representative page across viewports
@pytest.mark.visual
@pytest.mark.parametrize("name,width,height", [
("mobile", 375, 667),
("tablet", 768, 1024),
("desktop", 1280, 800),
])
def test_homepage_responsive(page: Page, name, width, height):
page.set_viewport_size({"width": width, "height": height})
page.goto("/")
expect(page).to_have_screenshot(
f"homepage-{name}.png",
animations="disabled",
)Three test functions: smoke pages, component variants, responsive sweep. Tagged with a visual marker so CI can run them independently — visual tests are heavier and you may not want them on every PR. Register the marker in pytest.ini:
markers =
visual: visual regression tests; runs slower, generates artefactsThen pytest -m visual to run only visuals; pytest -m "not visual" to skip them on a fast PR check.
Coming from Playwright TypeScript?
Identical workflow, snake_case parameters:
- TS
await expect(page).toHaveScreenshot('home.png', { fullPage: true })→ Pythonexpect(page).to_have_screenshot("home.png", full_page=True) - TS
await expect(component).toHaveScreenshot('btn.png')→ Pythonexpect(component).to_have_screenshot("btn.png") - TS
for (const vp of viewports) test(...)→ Python@pytest.mark.parametrize("...", [...])
The Python parametrize for responsive sweeps is genuinely cleaner than the TS for-loop pattern most teams write — it composes with pytest.param IDs, so the report shows test_homepage_responsive[mobile] instead of homepage 375x667.
⚠️ Common mistakes
- Visual-testing pages with random or live content. A dashboard with stock tickers, a search results page with ad slots, a product page with "recently viewed" carousels — every run produces a different render. Either mask everything dynamic (lots of code) or skip visual testing on those pages entirely (better).
- Mixing baselines from different operating systems. Capturing on macOS and running on Linux CI will fail every visual test. Either pin the visual job to a specific OS in CI, or generate baselines via a one-shot CI run and commit the resulting PNGs.
- Forgetting to gate visuals behind a marker. Visual tests are slower (large screenshots, pixel comparison) and generate artefacts on failure. If they run on every PR by default, the suite becomes painful. Tag them with
@pytest.mark.visualand run them in a separate CI job (often nightly, not per-PR).
🎯 Practice task
Build a visual suite with smoke, component, and responsive coverage. 30-40 minutes.
-
Create
tests/test_visual_strategy.py:import pytest from playwright.sync_api import Page, expect @pytest.mark.visual def test_login_page_full(page: Page): page.goto("https://www.saucedemo.com/") expect(page).to_have_screenshot( "login-full.png", full_page=True, animations="disabled", max_diff_pixel_ratio=0.01, ) @pytest.mark.visual def test_login_form_component(page: Page): page.goto("https://www.saucedemo.com/") form = page.locator(".login_wrapper-inner") expect(form).to_have_screenshot("login-form.png", animations="disabled") @pytest.mark.visual @pytest.mark.parametrize("name,width,height", [ ("mobile", 375, 667), ("tablet", 768, 1024), ("desktop", 1280, 800), ]) def test_login_responsive(page: Page, name, width, height): page.set_viewport_size({"width": width, "height": height}) page.goto("https://www.saucedemo.com/") expect(page).to_have_screenshot( f"login-{name}.png", animations="disabled", ) -
Register the marker in
pytest.ini:markers = visual: visual regression tests -
Generate all baselines:
pytest -m visual --update-snapshots. Five PNGs land undertests/__snapshots__/(one full-page, one form component, three responsive). -
Re-run:
pytest -m visual -v. All five pass — current matches baseline. -
Force diffs. Add a custom CSS injection at the top of one test:
page.add_style_tag(content="body { background: lightblue !important; }")Re-run; the test fails with a clear pixel-diff. Open
test-results/to see the diff. Remove the injection; the test passes again. -
Run only smoke (skip visuals).
pytest -m "not visual"— visual tests are skipped, regular tests run fast. -
Stretch: add a third dimension. Capture the inventory page after logging in, with
parametrizeover both viewport and product count (one product added vs three). Six baselines (3 viewports × 2 cart states), one test function, parametrized.
Visual testing is the surface that surfaces "looks broken" bugs. The next lesson is the parallel surface for "behaves broken for users with disabilities" bugs — accessibility testing with axe-playwright-python.