# Visual Regression Testing > **When to use**: Catching unintended visual changes -- layout shifts, style regressions, broken responsive designs, theme corruption -- that functional assertions miss. Visual tests answer "does it still look right?" after code changes. > **Prerequisites**: [core/configuration.md](configuration.md) for project setup, [core/assertions-and-waiting.md](assertions-and-waiting.md) for assertion basics. ## Quick Reference ```typescript // Page screenshot -- compare entire viewport await expect(page).toHaveScreenshot(); // Element screenshot -- compare a specific component await expect(page.getByTestId('pricing-card')).toHaveScreenshot(); // Named snapshot -- explicit file name await expect(page).toHaveScreenshot('homepage-hero.png'); // With threshold -- allow minor pixel differences await expect(page).toHaveScreenshot({ maxDiffPixelRatio: 0.01 }); // Mask dynamic content -- hide timestamps, avatars, ads await expect(page).toHaveScreenshot({ mask: [page.getByTestId('timestamp'), page.getByRole('img', { name: 'Avatar' })], }); // Disable animations -- prevent flaky diffs from CSS transitions await expect(page).toHaveScreenshot({ animations: 'disabled' }); // Update baselines (CLI) npx playwright test --update-snapshots ``` ## Patterns ### 1. Screenshot Comparison Basics **Use when**: Verifying that a page or component renders correctly after code changes. Best for pages with stable layouts -- landing pages, dashboards, settings panels. **Avoid when**: The page is highly dynamic with real-time data, live feeds, or content that changes on every load. Use functional assertions instead. Playwright compares a screenshot taken during the test against a stored baseline (golden) image. On first run, the baseline is created. On subsequent runs, differences cause the test to fail with a visual diff report. **TypeScript** ```typescript import { test, expect } from "@playwright/test" test("homepage renders correctly", async ({ page }) => { await page.goto("/") // Full page screenshot comparison await expect(page).toHaveScreenshot("homepage.png") }) test("pricing card matches design", async ({ page }) => { await page.goto("/pricing") // Element-level screenshot -- scoped to a single component const card = page.getByTestId("pro-plan-card") await expect(card).toHaveScreenshot("pro-plan-card.png") }) ``` **JavaScript** ```javascript const { test, expect } = require("@playwright/test") test("homepage renders correctly", async ({ page }) => { await page.goto("/") await expect(page).toHaveScreenshot("homepage.png") }) test("pricing card matches design", async ({ page }) => { await page.goto("/pricing") const card = page.getByTestId("pro-plan-card") await expect(card).toHaveScreenshot("pro-plan-card.png") }) ``` Snapshots are stored alongside tests by default in a folder named `-snapshots/`. The folder structure includes the project name and platform: ``` tests/ homepage.spec.ts homepage.spec.ts-snapshots/ homepage-chromium-linux.png homepage-chromium-darwin.png ``` ### 2. Configuring Thresholds **Use when**: Your UI has minor rendering differences between runs -- anti-aliasing, font hinting, sub-pixel rendering. Thresholds prevent false failures from pixel-level noise. **Avoid when**: You want pixel-perfect comparisons (design-system component libraries, icon rendering). Keep thresholds at zero. Three knobs control comparison sensitivity: | Option | What it controls | Good default | | ------------------- | ---------------------------------------------- | -------------------------------------------- | | `maxDiffPixels` | Absolute number of pixels that can differ | `100` for full pages, `10` for components | | `maxDiffPixelRatio` | Fraction of total pixels that can differ (0-1) | `0.01` (1%) for full pages | | `threshold` | Per-pixel color difference tolerance (0-1) | `0.2` for most UIs, `0.1` for design systems | **TypeScript** ```typescript import { test, expect } from "@playwright/test" test("dashboard allows minor rendering variance", async ({ page }) => { await page.goto("/dashboard") // Allow up to 1% of pixels to differ await expect(page).toHaveScreenshot("dashboard.png", { maxDiffPixelRatio: 0.01, }) }) test("icon renders pixel-perfect", async ({ page }) => { await page.goto("/icons") // Strict: zero tolerance await expect(page.getByTestId("logo")).toHaveScreenshot("logo.png", { maxDiffPixels: 0, threshold: 0, }) }) test("chart allows anti-aliasing differences", async ({ page }) => { await page.goto("/analytics") // Per-pixel color threshold + absolute pixel count cap await expect(page.getByTestId("revenue-chart")).toHaveScreenshot("revenue-chart.png", { threshold: 0.3, maxDiffPixels: 200, }) }) ``` **JavaScript** ```javascript const { test, expect } = require("@playwright/test") test("dashboard allows minor rendering variance", async ({ page }) => { await page.goto("/dashboard") await expect(page).toHaveScreenshot("dashboard.png", { maxDiffPixelRatio: 0.01, }) }) test("icon renders pixel-perfect", async ({ page }) => { await page.goto("/icons") await expect(page.getByTestId("logo")).toHaveScreenshot("logo.png", { maxDiffPixels: 0, threshold: 0, }) }) test("chart allows anti-aliasing differences", async ({ page }) => { await page.goto("/analytics") await expect(page.getByTestId("revenue-chart")).toHaveScreenshot("revenue-chart.png", { threshold: 0.3, maxDiffPixels: 200, }) }) ``` **Global thresholds** in `playwright.config.ts`: ```typescript // playwright.config.ts import { defineConfig } from "@playwright/test" export default defineConfig({ expect: { toHaveScreenshot: { maxDiffPixelRatio: 0.01, threshold: 0.2, animations: "disabled", }, }, }) ``` ### 3. Full Page vs Element Screenshots **Use when**: Deciding scope. Full page catches layout shifts and spacing regressions. Element screenshots isolate components and are more stable. **Avoid when**: Neither -- use both strategically. **TypeScript** ```typescript import { test, expect } from "@playwright/test" test("full page screenshot catches layout shifts", async ({ page }) => { await page.goto("/") // Captures the visible viewport await expect(page).toHaveScreenshot("homepage-viewport.png") // Captures the entire scrollable page await expect(page).toHaveScreenshot("homepage-full.png", { fullPage: true, }) }) test("element screenshot isolates component changes", async ({ page }) => { await page.goto("/pricing") // Only the pricing table -- immune to header/footer changes await expect(page.getByRole("table")).toHaveScreenshot("pricing-table.png") // A specific card within the page await expect(page.getByTestId("enterprise-card")).toHaveScreenshot("enterprise-card.png") }) ``` **JavaScript** ```javascript const { test, expect } = require("@playwright/test") test("full page screenshot catches layout shifts", async ({ page }) => { await page.goto("/") await expect(page).toHaveScreenshot("homepage-viewport.png") await expect(page).toHaveScreenshot("homepage-full.png", { fullPage: true, }) }) test("element screenshot isolates component changes", async ({ page }) => { await page.goto("/pricing") await expect(page.getByRole("table")).toHaveScreenshot("pricing-table.png") await expect(page.getByTestId("enterprise-card")).toHaveScreenshot("enterprise-card.png") }) ``` **Rule of thumb**: Use element screenshots for components that change independently. Use full page screenshots for key landing pages and layouts where spacing between sections matters. ### 4. Masking Dynamic Content **Use when**: The page contains content that changes between test runs -- timestamps, user avatars, ad slots, relative dates ("3 minutes ago"), random hero images, A/B test variants. **Avoid when**: All content is deterministic. Masking adds maintenance overhead. The `mask` option overlays a solid-colored box over specified locators before taking the screenshot. The masked region is excluded from comparison. **TypeScript** ```typescript import { test, expect } from "@playwright/test" test("dashboard with masked dynamic content", async ({ page }) => { await page.goto("/dashboard") await expect(page).toHaveScreenshot("dashboard.png", { mask: [ page.getByTestId("current-time"), page.getByTestId("user-avatar"), page.getByTestId("live-visitor-count"), page.locator(".ad-banner"), ], maskColor: "#FF00FF", // visible magenta -- makes it obvious what's masked in reviews }) }) test("feed page with relative timestamps", async ({ page }) => { await page.goto("/feed") // Mask all relative time elements at once await expect(page).toHaveScreenshot("feed.png", { mask: [page.locator("time[datetime]")], }) }) test("profile page with user-generated content", async ({ page }) => { await page.goto("/profile/test-user") await expect(page).toHaveScreenshot("profile.png", { mask: [ page.getByRole("img", { name: "Profile photo" }), page.getByTestId("last-login"), page.getByTestId("member-since"), ], }) }) ``` **JavaScript** ```javascript const { test, expect } = require("@playwright/test") test("dashboard with masked dynamic content", async ({ page }) => { await page.goto("/dashboard") await expect(page).toHaveScreenshot("dashboard.png", { mask: [ page.getByTestId("current-time"), page.getByTestId("user-avatar"), page.getByTestId("live-visitor-count"), page.locator(".ad-banner"), ], maskColor: "#FF00FF", }) }) test("feed page with relative timestamps", async ({ page }) => { await page.goto("/feed") await expect(page).toHaveScreenshot("feed.png", { mask: [page.locator("time[datetime]")], }) }) test("profile page with user-generated content", async ({ page }) => { await page.goto("/profile/test-user") await expect(page).toHaveScreenshot("profile.png", { mask: [ page.getByRole("img", { name: "Profile photo" }), page.getByTestId("last-login"), page.getByTestId("member-since"), ], }) }) ``` **Alternative: freeze dynamic content with JavaScript** When masking is not sufficient (e.g., content affects layout), inject JavaScript to freeze the content: ```typescript test("freeze clock before screenshot", async ({ page }) => { await page.goto("/dashboard") // Replace all dynamic timestamps with a fixed value await page.evaluate(() => { document.querySelectorAll('[data-testid="timestamp"]').forEach((el) => { el.textContent = "Jan 1, 2025 12:00 PM" }) }) await expect(page).toHaveScreenshot("dashboard-frozen.png") }) ``` ### 5. Animations Handling **Use when**: Always. CSS animations and transitions are the number one cause of flaky visual diffs. A screenshot captured mid-animation will never match the baseline. **Avoid when**: You are explicitly testing the animation itself (rare). **TypeScript** ```typescript import { test, expect } from "@playwright/test" test("page renders without animation interference", async ({ page }) => { await page.goto("/") // Disables CSS animations and transitions before screenshotting await expect(page).toHaveScreenshot("homepage.png", { animations: "disabled", }) }) ``` **JavaScript** ```javascript const { test, expect } = require("@playwright/test") test("page renders without animation interference", async ({ page }) => { await page.goto("/") await expect(page).toHaveScreenshot("homepage.png", { animations: "disabled", }) }) ``` **Set globally** -- this should be the default for every project: ```typescript // playwright.config.ts import { defineConfig } from "@playwright/test" export default defineConfig({ expect: { toHaveScreenshot: { animations: "disabled", }, }, }) ``` When `animations: 'disabled'` is set, Playwright: 1. Injects a stylesheet that sets `* { animation-duration: 0s !important; transition-duration: 0s !important; }`. 2. Waits for any currently running animations to finish (forces them to their end state). 3. Takes the screenshot. For JavaScript-driven animations (requestAnimationFrame, GSAP, Framer Motion), you may also need to wait for stability: ```typescript test("page with JS animations", async ({ page }) => { await page.goto("/animated-landing") // Wait for the hero animation to settle await page.getByTestId("hero-section").waitFor({ state: "visible" }) await page.waitForTimeout(500) // last resort for JS animations -- use sparingly await expect(page).toHaveScreenshot("landing.png", { animations: "disabled", }) }) ``` ### 6. Updating Snapshots **Use when**: You have intentionally changed the UI and need to update the baselines. A design refresh, rebrand, new feature, or layout change. **Avoid when**: The diff is unexpected -- investigate the cause before blindly updating. ```bash # Update all snapshots npx playwright test --update-snapshots # Update snapshots for a specific test file npx playwright test tests/homepage.spec.ts --update-snapshots # Update snapshots for a specific project (browser) npx playwright test --project=chromium --update-snapshots ``` **Workflow for reviewing snapshot changes:** 1. Run tests and observe failures in the HTML report: ```bash npx playwright test npx playwright show-report ``` The report shows the expected image, actual image, and a diff image side-by-side. 2. If the changes are intentional, update the snapshots: ```bash npx playwright test --update-snapshots ``` 3. Review the updated snapshots in your diff tool before committing: ```bash git diff --name-only # see which snapshot files changed ``` 4. Commit the updated snapshots with a clear message explaining why they changed. **Tip**: Never run `--update-snapshots` in CI. Always update locally, review the diffs, and commit the new baselines. **TypeScript -- helper for controlled updates** ```typescript import { test, expect } from "@playwright/test" // Tag visual tests so you can update them selectively test("homepage visual @visual", async ({ page }) => { await page.goto("/") await expect(page).toHaveScreenshot("homepage.png", { animations: "disabled", }) }) test("pricing visual @visual", async ({ page }) => { await page.goto("/pricing") await expect(page).toHaveScreenshot("pricing.png", { animations: "disabled", }) }) ``` ```bash # Update only visual tests npx playwright test --grep @visual --update-snapshots ``` ### 7. Cross-Browser Visual Testing **Use when**: Your users span Chrome, Firefox, and Safari and you need to verify rendering consistency per browser. **Avoid when**: Early stage projects targeting a single browser -- visual tests across three browsers triple the maintenance burden. Playwright automatically separates snapshots by project name. Each browser gets its own baseline file. This is correct behavior -- browsers render fonts, shadows, and anti-aliasing differently. **TypeScript** ```typescript // playwright.config.ts import { defineConfig, devices } from "@playwright/test" export default defineConfig({ testDir: "./tests", expect: { toHaveScreenshot: { animations: "disabled", maxDiffPixelRatio: 0.01, }, }, projects: [ { name: "chromium", use: { ...devices["Desktop Chrome"] }, }, { name: "firefox", use: { ...devices["Desktop Firefox"] }, }, { name: "webkit", use: { ...devices["Desktop Safari"] }, }, ], }) ``` ```typescript // tests/homepage.spec.ts import { test, expect } from "@playwright/test" test("homepage renders correctly per browser", async ({ page }) => { await page.goto("/") // Playwright creates separate baselines per project: // homepage.spec.ts-snapshots/homepage-chromium-linux.png // homepage.spec.ts-snapshots/homepage-firefox-linux.png // homepage.spec.ts-snapshots/homepage-webkit-linux.png await expect(page).toHaveScreenshot("homepage.png", { animations: "disabled", }) }) ``` **JavaScript** ```javascript const { test, expect } = require("@playwright/test") test("homepage renders correctly per browser", async ({ page }) => { await page.goto("/") await expect(page).toHaveScreenshot("homepage.png", { animations: "disabled", }) }) ``` **Strategy for managing cross-browser snapshots**: Run visual tests in a single browser (Chromium on Linux in CI) to minimize snapshot count and only add other browsers when you have actual cross-browser rendering bugs. You can scope visual tests to one project: ```typescript // playwright.config.ts import { defineConfig, devices } from "@playwright/test" export default defineConfig({ projects: [ { name: "visual", testMatch: "**/*.visual.spec.ts", use: { ...devices["Desktop Chrome"] }, }, { name: "chromium", testIgnore: "**/*.visual.spec.ts", use: { ...devices["Desktop Chrome"] }, }, { name: "firefox", testIgnore: "**/*.visual.spec.ts", use: { ...devices["Desktop Firefox"] }, }, ], }) ``` ### 8. Responsive Visual Testing **Use when**: Your application has responsive breakpoints and you need to verify layouts at different viewport sizes. **Avoid when**: The page has a single fixed-width layout. **TypeScript** ```typescript import { test, expect } from "@playwright/test" const viewports = [ { name: "mobile", width: 375, height: 812 }, { name: "tablet", width: 768, height: 1024 }, { name: "desktop", width: 1440, height: 900 }, ] for (const viewport of viewports) { test(`homepage at ${viewport.name} (${viewport.width}x${viewport.height})`, async ({ page }) => { await page.setViewportSize({ width: viewport.width, height: viewport.height }) await page.goto("/") await expect(page).toHaveScreenshot(`homepage-${viewport.name}.png`, { animations: "disabled", fullPage: true, }) }) } ``` **JavaScript** ```javascript const { test, expect } = require("@playwright/test") const viewports = [ { name: "mobile", width: 375, height: 812 }, { name: "tablet", width: 768, height: 1024 }, { name: "desktop", width: 1440, height: 900 }, ] for (const viewport of viewports) { test(`homepage at ${viewport.name} (${viewport.width}x${viewport.height})`, async ({ page }) => { await page.setViewportSize({ width: viewport.width, height: viewport.height }) await page.goto("/") await expect(page).toHaveScreenshot(`homepage-${viewport.name}.png`, { animations: "disabled", fullPage: true, }) }) } ``` **Alternative: use Playwright projects for responsive testing.** ```typescript // playwright.config.ts import { defineConfig, devices } from "@playwright/test" export default defineConfig({ projects: [ { name: "desktop", testMatch: "**/*.visual.spec.ts", use: { ...devices["Desktop Chrome"], viewport: { width: 1440, height: 900 }, }, }, { name: "tablet", testMatch: "**/*.visual.spec.ts", use: { ...devices["iPad (gen 7)"], }, }, { name: "mobile", testMatch: "**/*.visual.spec.ts", use: { ...devices["iPhone 14"], }, }, ], }) ``` This approach gives you separate snapshot folders per device and runs them in parallel. ### 9. CI Setup for Visual Tests **Use when**: Running visual regression tests in CI. The critical requirement is consistent rendering -- the same test must produce the same screenshot every time. **Avoid when**: Never skip this. Visual tests without CI consistency are worthless. **The problem**: Font rendering, anti-aliasing, and sub-pixel rendering differ across operating systems. A snapshot taken on macOS will not match one taken on Linux. This is the most common source of visual test pain. **The solution**: Run visual tests inside Docker using the official Playwright container. Generate and update snapshots from the same container. **GitHub Actions with Docker** ```yaml # .github/workflows/visual-tests.yml name: Visual Regression Tests on: [push, pull_request] jobs: visual-tests: runs-on: ubuntu-latest container: image: mcr.microsoft.com/playwright:v1.50.0-noble steps: - uses: actions/checkout@v4 - uses: actions/setup-node@v4 with: node-version: 20 cache: npm - run: npm ci - name: Run visual tests run: npx playwright test --project=visual env: HOME: /root # required for Playwright in Docker - uses: actions/upload-artifact@v4 if: failure() with: name: visual-test-report path: playwright-report/ retention-days: 14 ``` **Updating snapshots locally using Docker** -- so your local baselines match CI: ```bash # Generate/update snapshots using the same container as CI docker run --rm -v $(pwd):/work -w /work \ mcr.microsoft.com/playwright:v1.50.0-noble \ npx playwright test --update-snapshots --project=visual ``` **Add a script to `package.json`** for convenience: ```json { "scripts": { "test:visual": "npx playwright test --project=visual", "test:visual:update": "docker run --rm -v $(pwd):/work -w /work mcr.microsoft.com/playwright:v1.50.0-noble npx playwright test --update-snapshots --project=visual" } } ``` **Configure snapshots for Linux only** in your config: ```typescript // playwright.config.ts import { defineConfig, devices } from "@playwright/test" export default defineConfig({ snapshotPathTemplate: "{testDir}/{testFileDir}/{testFileName}-snapshots/{arg}{-projectName}{ext}", // Omits {-snapshotSuffix} which includes the platform name (linux, darwin, win32). // This means snapshots are platform-agnostic -- you MUST generate them in Docker. projects: [ { name: "visual", testMatch: "**/*.visual.spec.ts", use: { ...devices["Desktop Chrome"] }, }, ], }) ``` ### 10. Component Visual Testing **Use when**: Testing individual UI components in isolation -- buttons, cards, forms, modals. Faster than full-page screenshots, more stable, and easier to maintain. **Avoid when**: You need to verify interactions between multiple components or page-level layout. **TypeScript -- using Playwright component testing** ```typescript // tests/components/button.visual.spec.ts import { test, expect } from "@playwright/test" test.describe("Button component visual states", () => { test("primary button", async ({ page }) => { await page.goto("/storybook/iframe.html?id=button--primary") const button = page.getByRole("button") await expect(button).toHaveScreenshot("button-primary.png", { animations: "disabled", }) }) test("primary button hover", async ({ page }) => { await page.goto("/storybook/iframe.html?id=button--primary") const button = page.getByRole("button") await button.hover() await expect(button).toHaveScreenshot("button-primary-hover.png", { animations: "disabled", }) }) test("primary button disabled", async ({ page }) => { await page.goto("/storybook/iframe.html?id=button--primary-disabled") const button = page.getByRole("button") await expect(button).toHaveScreenshot("button-primary-disabled.png", { animations: "disabled", }) }) test("button sizes", async ({ page }) => { for (const size of ["small", "medium", "large"]) { await page.goto(`/storybook/iframe.html?id=button--${size}`) const button = page.getByRole("button") await expect(button).toHaveScreenshot(`button-${size}.png`, { animations: "disabled", }) } }) }) ``` **JavaScript** ```javascript const { test, expect } = require("@playwright/test") test.describe("Button component visual states", () => { test("primary button", async ({ page }) => { await page.goto("/storybook/iframe.html?id=button--primary") const button = page.getByRole("button") await expect(button).toHaveScreenshot("button-primary.png", { animations: "disabled", }) }) test("primary button hover", async ({ page }) => { await page.goto("/storybook/iframe.html?id=button--primary") const button = page.getByRole("button") await button.hover() await expect(button).toHaveScreenshot("button-primary-hover.png", { animations: "disabled", }) }) test("primary button disabled", async ({ page }) => { await page.goto("/storybook/iframe.html?id=button--primary-disabled") const button = page.getByRole("button") await expect(button).toHaveScreenshot("button-primary-disabled.png", { animations: "disabled", }) }) test("button sizes", async ({ page }) => { for (const size of ["small", "medium", "large"]) { await page.goto(`/storybook/iframe.html?id=button--${size}`) const button = page.getByRole("button") await expect(button).toHaveScreenshot(`button-${size}.png`, { animations: "disabled", }) } }) }) ``` **Using a dedicated visual test page** instead of Storybook: ```typescript // tests/components/card.visual.spec.ts import { test, expect } from "@playwright/test" test.describe("Card component", () => { test.beforeEach(async ({ page }) => { // Navigate to a page that renders the component in isolation await page.goto("/test-harness/card") }) test("default state", async ({ page }) => { await expect(page.getByTestId("card")).toHaveScreenshot("card-default.png", { animations: "disabled", }) }) test("with long content truncates correctly", async ({ page }) => { await page.goto("/test-harness/card?content=long") await expect(page.getByTestId("card")).toHaveScreenshot("card-long-content.png", { animations: "disabled", }) }) test("error state", async ({ page }) => { await page.goto("/test-harness/card?state=error") await expect(page.getByTestId("card")).toHaveScreenshot("card-error.png", { animations: "disabled", }) }) }) ``` ## Decision Guide | Scenario | Recommended Approach | Why | | ------------------------------------------------- | ---------------------------------------------------------- | ---------------------------------------------------------- | | Key landing pages, marketing site | Full page screenshot, `fullPage: true` | Catches layout shifts, spacing, and overall visual harmony | | Individual UI components (buttons, cards, modals) | Element screenshot on the component | Isolated, fast, stable -- immune to unrelated page changes | | Page with dynamic content (timestamps, live data) | Full page + `mask` on dynamic elements | Covers layout while ignoring volatile content | | Design system component library | Element screenshot per variant, zero threshold | Pixel-perfect enforcement for shared components | | Responsive layout verification | Screenshot per viewport (loop or projects) | Catches breakpoint bugs at mobile/tablet/desktop | | Cross-browser rendering consistency | Separate snapshots per browser project | Browsers render fonts and shadows differently | | CI pipeline | Docker container (Playwright image), Linux-only snapshots | Consistent rendering, no OS-dependent diffs | | Pixel threshold: design system | `threshold: 0`, `maxDiffPixels: 0` | Zero tolerance for component library | | Pixel threshold: content pages | `maxDiffPixelRatio: 0.01`, `threshold: 0.2` | Allows minor anti-aliasing variance | | Pixel threshold: charts and graphs | `maxDiffPixels: 200`, `threshold: 0.3` | Anti-aliasing on curves varies across runs | | Visual tests add value | Stable pages, design systems, post-refactor verification | Clear baseline, predictable content | | Visual tests are noise | Highly dynamic pages, real-time dashboards, A/B test pages | Content changes on every load, diffs are meaningless | ## Anti-Patterns | Don't Do This | Problem | Do This Instead | | ------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------- | | Visual testing every page in the app | Massive snapshot maintenance, constant false failures, team ignores results | Pick 5-10 key pages and critical components. Quality over quantity. | | Not masking dynamic content (timestamps, avatars, counters) | Screenshots differ on every run. Tests are permanently flaky. | Use `mask` option for all dynamic elements. Audit pages for volatility before adding visual tests. | | Running visual tests across macOS, Linux, and Windows | Font rendering differs per OS. Snapshots never match cross-platform. | Standardize on Linux via Docker. Generate and run snapshots in the same Playwright Docker container. | | Not using Docker in CI for visual tests | CI runner OS updates, font changes, or library upgrades silently shift rendering. | Pin a specific Playwright Docker image version. Update intentionally. | | Updating snapshots blindly with `--update-snapshots` | Accepts unintentional regressions. The baseline becomes wrong. | Always review the diff in the HTML report first. Understand why the snapshot changed. | | Skipping `animations: 'disabled'` | CSS transitions and keyframe animations create random diffs. One of the top causes of flaky visual tests. | Set `animations: 'disabled'` globally in config. | | Using visual tests instead of functional assertions | Screenshot diffs do not tell you _what_ broke -- just that _something_ looks different. Slow to debug. | Use functional assertions (`toHaveText`, `toBeVisible`) for behavior. Visual tests complement, never replace. | | Committing snapshots generated on different platforms | The repo has macOS snapshots from dev A, Linux snapshots from dev B. Tests fail for everyone. | All team members generate snapshots using the same Docker container. Add a `test:visual:update` script. | | Setting threshold too high (e.g., `maxDiffPixelRatio: 0.1`) | 10% of pixels can change and the test still passes. Defeats the purpose. | Start with `0.01` (1%) and adjust per-test if needed. | | Full page screenshots on pages with infinite scroll or lazy loading | Page height is nondeterministic. Screenshots vary by load timing. | Use element screenshots on the above-the-fold content, or scroll to a deterministic state first. | ## Troubleshooting ### "Screenshot comparison failed" on first CI run after local development **Cause**: Snapshots were generated on macOS (or Windows) locally. CI runs on Linux. Font rendering differs between operating systems, so every pixel comparison fails. **Fix**: Generate snapshots using Docker locally so they match CI: ```bash docker run --rm -v $(pwd):/work -w /work \ mcr.microsoft.com/playwright:v1.50.0-noble \ npx playwright test --update-snapshots --project=visual ``` Commit the Linux-generated snapshots. Never commit macOS-generated snapshots if CI runs on Linux. ### "Expected screenshot to match but X pixels differ" **Cause**: Minor rendering differences from anti-aliasing, font hinting, or sub-pixel rendering. Common with text-heavy pages and curved shapes (charts, rounded corners). **Fix**: Add a small tolerance: ```typescript await expect(page).toHaveScreenshot("page.png", { maxDiffPixelRatio: 0.01, // allow 1% variance threshold: 0.2, // per-pixel color tolerance }) ``` If the diff is larger, check the HTML report. Look at the diff image to determine if the change is a real regression or rendering noise. ### Visual tests pass locally but fail in CI (even with Docker) **Cause**: Different Playwright versions locally vs CI. The Docker image pins a specific Playwright version. If your local `@playwright/test` is newer, the rendering engine may differ. **Fix**: Ensure the Playwright version in `package.json` matches the Docker image tag: ```json { "devDependencies": { "@playwright/test": "1.50.0" } } ``` ```yaml # CI config container: image: mcr.microsoft.com/playwright:v1.50.0-noble # must match ``` ### Animations cause random diff failures **Cause**: CSS animations or transitions captured mid-frame. The screenshot is taken at a non-deterministic point in the animation timeline. **Fix**: Set `animations: 'disabled'` globally: ```typescript // playwright.config.ts export default defineConfig({ expect: { toHaveScreenshot: { animations: "disabled", }, }, }) ``` For JavaScript-driven animations, wait for a stable state before screenshotting. As a last resort, use `page.waitForTimeout(300)` after the animation trigger. ### Snapshot file names conflict between tests **Cause**: Two tests in different files use the same screenshot name (`'homepage.png'`) without unique file paths. **Fix**: Playwright includes the test file name in the snapshot path by default. If you still have conflicts, use explicit unique names: ```typescript await expect(page).toHaveScreenshot("auth-homepage.png") // in auth.spec.ts await expect(page).toHaveScreenshot("public-homepage.png") // in public.spec.ts ``` Or customize the snapshot path template in config: ```typescript export default defineConfig({ snapshotPathTemplate: "{testDir}/{testFileDir}/{testFileName}-snapshots/{arg}{-projectName}{ext}", }) ``` ### Too many snapshot files to maintain **Cause**: Visual tests for every page, every browser, every viewport. The snapshot count explodes. **Fix**: Be selective. Visual test only pages where visual regressions are high-risk: - Landing pages and marketing pages - Design system components - Complex layouts (dashboards, data tables) - Pages after a major refactor Skip pages where functional assertions already cover the key elements. A `toHaveText` and `toBeVisible` check is often enough. ## Related - [core/assertions-and-waiting.md](assertions-and-waiting.md) -- `toHaveScreenshot()` is a web-first assertion and follows the same retry semantics - [core/configuration.md](configuration.md) -- global screenshot config, project setup, `snapshotPathTemplate` - [core/mobile-and-responsive.md](mobile-and-responsive.md) -- responsive viewport testing patterns - [ci/docker-and-containers.md](../ci/docker-and-containers.md) -- Docker setup for consistent rendering - [ci/ci-github-actions.md](../ci/ci-github-actions.md) -- CI pipeline configuration for visual tests