The previous lesson introduced toHaveScreenshot. The single biggest decision when reaching for it is scope — full-page or element-level? Each catches different bugs and breaks under different conditions. Get it wrong and you have a suite that's either far too brittle (every minor design tweak fails 50 tests) or far too narrow (the actual layout regression slips through). This lesson is the trade-offs, the responsive-testing pattern that scales to multiple viewports, and the cross-browser baseline strategy that keeps CI green when the same page renders ever so slightly differently in WebKit.
Full-page screenshots
The default toHaveScreenshot() captures only the visible viewport. To capture the whole scrollable page:
await expect(page).toHaveScreenshot("home-fullpage.png", {
fullPage: true,
animations: "disabled"
});Now the screenshot includes everything below the fold — testimonials, the footer, the long pricing table. Useful for landing pages, documentation, marketing pages where the whole layout matters.
The trade-off: full-page screenshots fail on every change anywhere on the page. A footer redesign breaks your homepage test. A new banner pushes everything down 80 pixels and the diff is everywhere. They're useful but blunt — reach for them for layout regression checks specifically.
Element-level screenshots
Most useful visual checks scope to one component:
const card = page.getByTestId("product-card").first();
await expect(card).toHaveScreenshot("product-card.png");
const footer = page.getByRole("contentinfo");
await expect(footer).toHaveScreenshot("footer.png");
const pricingTable = page.getByTestId("pricing-table");
await expect(pricingTable).toHaveScreenshot("pricing-table.png", {
animations: "disabled",
maxDiffPixelRatio: 0.01
});Element snapshots are far less brittle. A header redesign won't fail your pricing-card test. A typo fix in the footer won't fail your hero test. The diff is scoped to the component you cared about. For component-level visual coverage, this is what you want by default.
When to use which
- Full-page — landing pages, documentation, anything where the holistic visual experience matters. A few of these per app, capturing the most important "marketing surfaces."
- Element-level — design system components, repeating cards, headers, footers, modals. Many of these, scoped to the component under test.
A typical mature suite has 3-5 full-page snapshots (homepage, pricing, contact, key landing pages) and 30-100 element snapshots (every component in the design system, every card variant, every modal state).
Responsive visual testing
The same component can render entirely differently across viewports. Instead of one test, parameterise by viewport:
import { test, expect } from "@playwright/test";
const VIEWPORTS = [
{ name: "mobile", width: 375, height: 667 },
{ name: "tablet", width: 768, height: 1024 },
{ name: "desktop", width: 1280, height: 720 }
];
for (const vp of VIEWPORTS) {
test(`homepage at ${vp.name}`, async ({ page }) => {
await page.setViewportSize({ width: vp.width, height: vp.height });
await page.goto("/");
await expect(page).toHaveScreenshot(`homepage-${vp.name}.png`, {
fullPage: true,
animations: "disabled"
});
});
}Three tests, three baselines, three viewports. A change that breaks the mobile layout but not the desktop one fails the mobile snapshot specifically — and the failure points exactly at the viewport that broke.
The same idea works at the project level (chapter 6's mobile emulation lesson). Run the same spec under three projects (Mobile Chrome, iPad, Desktop Chrome) and you get one baseline per project automatically — no loop needed.
Cross-browser visual baselines
Each browser stores its own baseline. The directory structure ends up looking like:
tests/
└── visual.spec.ts-snapshots/
├── homepage-chromium-darwin.png
├── homepage-chromium-linux.png
├── homepage-firefox-darwin.png
├── homepage-firefox-linux.png
├── homepage-webkit-darwin.png
└── homepage-webkit-linux.png
Three browsers × two platforms (your local Mac/Linux + CI Linux) = six baselines per snapshot. Looks like a lot, but it's correct — Chromium-on-Mac genuinely renders text differently from Chromium-on-Linux, and committing both baselines is what makes both pass.
Two strategies for managing this in real teams:
- Generate baselines in CI only. Local runs of
--update-snapshotsaren't committed; the CI workflow runs--update-snapshotson demand (manually triggered or on aupdate-baselinesbranch), commits the new PNGs, opens a PR. This guarantees baselines match the CI environment exactly. Eliminates "passes locally, fails in CI." - Pin to Docker. Both local and CI runs happen inside Playwright's official Docker image (
mcr.microsoft.com/playwright). Same fonts, same rendering, same baselines work everywhere. Chapter 8's Docker lesson covers the full setup.
For most teams, Docker is the more sustainable path — your local runs match CI byte-for-byte.
The visual-regression workflow
A complete responsive + element spec
A real test file that covers a homepage at three viewports plus key components:
import { test, expect } from "@playwright/test";
test.describe("Visual regression — landing page", () => {
const viewports = {
mobile: { width: 375, height: 667 },
tablet: { width: 768, height: 1024 },
desktop: { width: 1280, height: 720 }
};
for (const [name, viewport] of Object.entries(viewports)) {
test.describe(`${name} (${viewport.width}x${viewport.height})`, () => {
test.use({ viewport });
test("full-page baseline", async ({ page }) => {
await page.goto("/");
await expect(page).toHaveScreenshot(`homepage-${name}.png`, {
fullPage: true,
animations: "disabled",
mask: [page.locator(".timestamp"), page.locator(".live-counter")]
});
});
test("hero section", async ({ page }) => {
await page.goto("/");
const hero = page.getByTestId("hero");
await expect(hero).toHaveScreenshot(`hero-${name}.png`, {
animations: "disabled"
});
});
test("pricing card", async ({ page }) => {
await page.goto("/pricing");
const card = page.getByTestId("pricing-card-pro");
await expect(card).toHaveScreenshot(`pricing-card-${name}.png`, {
animations: "disabled"
});
});
});
}
});Three viewports × three tests = nine baselines. The for/of loop is one of the most useful patterns for visual testing — change the viewports list to add iPad Mini, ultra-wide, or whatever your design system supports, and every test fans out automatically.
Visual diffs in the HTML report
When a visual test fails, Playwright's HTML reporter is where you diagnose:
npm run reportThe report shows three images side by side:
- Expected — the committed baseline.
- Actual — what this run captured.
- Diff — pixel differences highlighted in red.
You can switch between the three with a tab. If the diff highlights only a single button's colour, the test is doing its job. If the diff highlights huge swathes, you've probably hit an environment mismatch (different OS, different font installed) — see the cross-browser baselines section above.
Threshold tuning — a practical recipe
For a new visual test, start strict and loosen if the test is genuinely flaky:
// Default — strict
await expect(card).toHaveScreenshot("card.png");
// First sign of flake — small per-pixel tolerance
await expect(card).toHaveScreenshot("card.png", { threshold: 0.2 });
// Still flaky — small percentage tolerance
await expect(card).toHaveScreenshot("card.png", {
threshold: 0.2,
maxDiffPixelRatio: 0.01
});The wrong direction is "ratchet up tolerance until it stops failing" — that's how you end up with a test that passes anything because the threshold became 30%. If a test still flakes at 1% tolerance, the actual cause is usually animations, dynamic content, or a font-rendering mismatch. Fix the cause, not the threshold.
Coming from Cypress?
The mappings:
cy.matchImageSnapshot()(third-party plugin) →await expect(page).toHaveScreenshot()(built-in).cy.matchImageSnapshot({ failureThreshold: 0.01 })→await expect(page).toHaveScreenshot({ maxDiffPixelRatio: 0.01 }).- Element snapshots in Cypress need the plugin's specific syntax → Playwright
await expect(locator).toHaveScreenshot()works on any locator natively.
The win on migration is consolidation: your Cypress visual tests, plugin config, and CI tweaks become one config block in playwright.config.ts, and the test syntax matches your other Playwright assertions.
⚠️ Common mistakes
- Snapshotting full-page when an element would do. A footer change breaks your home-hero test. A header refactor breaks your pricing-card test. Element-level snapshots scope failures to the actual change. Reach for
fullPage: trueonly when the layout itself is the contract. - Committing baselines from a different OS than CI. You generate baselines on macOS, CI runs Linux, fonts render differently, every visual test fails. Pin the renderer (Docker) or generate baselines in CI directly.
- Iterating on
maxDiffPixelRatioto chase flakes. Each ratchet hides more bugs. If a test is genuinely unstable below 5% tolerance, the issue is animation, dynamic content, or environment — not the threshold. Disable animations, mask dynamic regions, fix the renderer, then tune tolerance.
🎯 Practice task
Build a responsive visual suite at three viewports. 25-30 minutes.
-
Create
tests/responsive-visual.spec.ts:import { test, expect } from "@playwright/test"; const viewports = { mobile: { width: 375, height: 667 }, tablet: { width: 768, height: 1024 }, desktop: { width: 1280, height: 720 } }; test.describe("Responsive visual regression — Sauce Demo", () => { for (const [name, viewport] of Object.entries(viewports)) { test.describe(`${name}`, () => { test.use({ viewport }); test.beforeEach(async ({ page }) => { await page.goto("https://www.saucedemo.com"); await page.getByPlaceholder("Username").fill("standard_user"); await page.getByPlaceholder("Password").fill("secret_sauce"); await page.getByRole("button", { name: "Login" }).click(); await expect(page).toHaveURL(/inventory/); }); test("inventory full-page", async ({ page }) => { await expect(page).toHaveScreenshot(`inventory-${name}.png`, { fullPage: true, animations: "disabled", mask: [page.locator(".footer_copy")] }); }); test("first inventory card", async ({ page }) => { const card = page.locator(".inventory_item").first(); await expect(card).toHaveScreenshot(`product-card-${name}.png`, { animations: "disabled" }); }); }); } }); -
Run it once with
--project=chromiumto generate baselines:npx playwright test responsive-visual.spec.ts --project=chromium. Six PNGs land in__snapshots__/. -
Run again — all six pass with zero diff.
-
Force a viewport-specific failure. Inspect element on the mobile cart badge, change its colour via devtools (
.shopping_cart_link { background: red }), rerun the mobile test only. The mobile snapshot fails; tablet and desktop still pass. The HTML report shows the red badge in the diff. This is the responsive-visual sweet spot: failures point at the exact viewport that broke. -
Update only the mobile baseline.
npx playwright test responsive-visual.spec.ts --grep "mobile" --update-snapshots --project=chromium. Only the mobile PNG regenerates; tablet and desktop are untouched. Useful for "I changed only the mobile design intentionally." -
Stretch: run the suite under all three browsers (
npx playwright test responsive-visual.spec.ts). The first run generates 18 baselines (6 snapshots × 3 browsers). On subsequent runs, all 18 should pass. Inspect the per-browser PNGs visually — even at the same viewport, Chromium and WebKit render fonts subtly differently. That's why per-browser baselines are necessary.
You now know exactly when to reach for full-page vs element snapshots, how to scale them across viewports, and how to manage cross-browser baselines without descending into "all my visual tests are flaky." The next lesson moves from visual to accessibility — the @axe-core/playwright integration that catches WCAG violations the eye misses.