Visual Regression

11Visual Regression Testing

What you will master here

  • What visual regression catches that DOM assertions can't
  • toHaveScreenshot — baseline, diff, threshold
  • Masking dynamic regions (dates, ads, animations)
  • Stabilising rendering before snapshot (fonts, animations, lazy images)
  • Per-project baselines (browser, OS, viewport)
  • Updating baselines safely and reviewing diffs in CI
  • When NOT to use visual tests

11.1 Why visual testing exists

DOM assertions catch structural bugs ("the button exists, has text 'Buy'"). They miss:

Visual regression takes a pixel snapshot and diffs it against an approved baseline. If they differ beyond a threshold, the test fails with a diff image showing exactly what changed.

11.2 The basics — toHaveScreenshot

test('homepage looks right', async ({ page }) => {
  await page.goto('/');
  await expect(page).toHaveScreenshot('home.png');                       // full page
  await expect(page.locator('.invoice')).toHaveScreenshot('invoice.png'); // element only
});

First run creates the baseline in __snapshots__/home.spec.ts/home.png. Later runs compare against it. Diff stored next to the failure as home-diff.png.

11.3 Stabilising the page before snapshot

The single biggest source of flake. Common sources of pixel noise:

SourceFix
Fonts still loadingawait page.evaluate(() => document.fonts.ready);
CSS animationsUse animations: 'disabled' option (default since 1.20)
Cursor blink, video autoplaycaret: 'hide'; pause media in beforeEach
Lazy imagesScroll page or use fullPage: true which triggers lazy loaders
Date / "2 minutes ago" textMask the element (next section)
Random ads / bannersBlock via page.route or mask
Anti-aliasing across OSPer-project baselines; run on identical OS in CI
test('product card snapshot', async ({ page }) => {
  await page.goto('/products/42');
  /* wait for everything to settle */
  await page.evaluate(() => document.fonts.ready);
  await page.waitForLoadState('networkidle');

  await expect(page.locator('.product-card')).toHaveScreenshot('card.png', {
    animations: 'disabled',
    caret: 'hide',
    maxDiffPixelRatio: 0.01,   // allow 1% pixel drift
  });
});

11.4 Masking dynamic regions

Some elements legitimately change every run (dates, prices fetched live, user avatar). Mask them so the screenshot diff ignores those areas.

await expect(page).toHaveScreenshot('orders.png', {
  mask: [
    page.locator('.order-date'),
    page.locator('.live-price'),
    page.getByTestId('promo-banner'),
  ],
  /* default mask colour is magenta — override if needed */
  maskColor: '#000',
});
Baseline (approved) Order #ABC123 2024-01-15 (masked) Status: paid Current run Order #ABC123 2026-06-19 (masked, ignored) Status: paid
Figure 11 — Masked regions are filled with a flat colour before diff; only un-masked areas need to match.

11.5 Threshold tuning

OptionWhat it does
maxDiffPixels: 100Allow up to 100 different pixels (absolute count)
maxDiffPixelRatio: 0.01Allow up to 1% of pixels to differ
threshold: 0.2Per-pixel sensitivity (0 = exact, 1 = max). Lower = stricter
Start strict, loosen on noiseBegin with defaults (threshold 0.2). If you see repeated 1-pixel-different failures across OSes/fonts, raise maxDiffPixelRatio to 0.005 or 0.01 rather than disabling the test.

11.6 Per-project baselines

Pixel rendering differs by browser engine, OS, and font availability. Don't share one baseline across all projects — Playwright already names baselines by project automatically.

// playwright.config.ts
export default defineConfig({
  expect: {
    toHaveScreenshot: { maxDiffPixelRatio: 0.01, animations: 'disabled', caret: 'hide' },
  },
  projects: [
    { name: 'chromium', use: { ...devices['Desktop Chrome'] } },
    { name: 'firefox',  use: { ...devices['Desktop Firefox'] } },
    { name: 'webkit',   use: { ...devices['Desktop Safari'] } },
  ],
});
/* baseline path becomes:
   __snapshots__/page.spec.ts-snapshots/home-chromium-linux.png
   __snapshots__/page.spec.ts-snapshots/home-firefox-linux.png
   ...
*/
Critical for CIGenerate baselines on the same OS image as CI runs. macOS-generated baselines will fail on Linux CI due to font rendering. Use a Docker image (mcr.microsoft.com/playwright) locally to match.

11.7 Updating baselines

# After an intentional UI change, regenerate baselines locally
npx playwright test --update-snapshots

# Or just for a specific file
npx playwright test tests/home.spec.ts --update-snapshots

Reviewing baseline changes in PRs

Commit baseline files (PNGs) alongside the code change. PR reviewer must see the new baselines. Tooling tip: use a service like Argos, Percy, or a custom GitHub action that renders side-by-side baseline / actual / diff in PR comments.

11.8 CI artifact diffing

When a visual test fails on CI, Playwright drops three PNGs in the test-results folder:

Upload them as artifacts; the HTML report also embeds them.

# GitHub Actions step (add to your workflow)
- name: Upload visual diffs
  if: failure()
  uses: actions/upload-artifact@v4
  with:
    name: visual-diffs
    path: test-results/**/*.png

11.9 When NOT to use visual tests

11.10 A typical visual suite layout

// tests/visual/components.spec.ts
import { test, expect } from '@playwright/test';

const COMPONENTS = ['button', 'input', 'badge', 'modal', 'tooltip'] as const;

for (const name of COMPONENTS){
  test(`${name} matches baseline`, async ({ page }) => {
    await page.goto(`/storybook?path=/story/${name}`);
    await page.evaluate(() => document.fonts.ready);
    await expect(page.locator('#root')).toHaveScreenshot(`${name}.png`);
  });
}

Module 13 — Interview Q&A bank

What does visual regression catch that DOM tests miss?
CSS regressions (invisible button, wrong colour), layout breaks (overflow, cut-off modals), icon swaps, z-order issues, spacing drift. Anything that looks wrong to a user but reads fine in the DOM.
How does toHaveScreenshot work?
First run captures and saves a baseline PNG. Subsequent runs capture a fresh screenshot and diff it pixel-by-pixel against the baseline. If the diff exceeds the configured threshold, the test fails and Playwright writes expected/actual/diff PNGs for review.
What stabilisation do you do before a snapshot?
Wait for fonts to load, disable animations, hide the caret, wait for network idle, scroll to trigger lazy images, mask dynamic regions (dates, prices, live counters), block third-party scripts (ads, analytics).
What is masking and when do you use it?
Filling specific elements with a solid colour before diff so their content doesn't affect the result. Use for legitimately dynamic regions — timestamps, live prices, user avatars, ad slots — that you don't want to fail the test.
What's the difference between threshold and maxDiffPixelRatio?
threshold is per-pixel sensitivity (how different two pixels must be to count as "different"). maxDiffPixelRatio is the overall budget — how many differing pixels are tolerated. Lowering threshold makes each pixel strict; raising maxDiffPixelRatio tolerates more total drift.
Why do visual tests pass locally but fail on CI?
Different OS = different font rendering and antialiasing. Generate baselines on the same OS image CI uses (e.g. run via the Playwright Docker image locally). Commit those baselines; never generate them on macOS for a Linux CI.
How do you update baselines after an intentional UI change?
npx playwright test --update-snapshots regenerates them. Commit the new PNGs in the same PR as the UI change so reviewers see both the code and visual delta.
Should you commit baseline images to git?
Yes — they're part of the test contract. A reviewer must be able to look at the PR diff and confirm the new look is intentional. Large binaries are fine; Git LFS is overkill unless you have thousands.
Where do you draw the line between visual and functional tests?
Use functional (DOM/text) for behaviour and logic. Use visual for appearance — only for pages where appearance is a contract (landing pages, design-system components, branded emails). Don't visual-test every page; the maintenance cost dominates.
What's a good first place to add visual tests?
Storybook (or any component-isolation environment). Each story renders one component in known states; capturing snapshots there is stable and catches CSS regressions early in the design-system layer, before they reach product pages.
What if a visual test diffs by 3 pixels every run?
Investigate first — that's antialiasing or sub-pixel rounding drift from a non-deterministic source (animation not fully disabled, font fallback). If unavoidable and not a real change, raise maxDiffPixelRatio slightly (e.g. 0.005). Don't disable the test.
How do you handle dark-mode in visual tests?
Set use: { colorScheme: 'dark' } per project or per file. Baselines are per-project, so dark-mode and light-mode get their own snapshots.
Should visual tests run on every PR?
For component-level (Storybook) tests, yes — fast and stable. For full-page visual tests, often a nightly run or only on UI-touching PRs, because they are heavier and noisier.
How do you visual-test responsive layouts?
Per-viewport project — declare projects for desktop / tablet / phone widths. Each gets its own baseline. Run the same tests across all projects to catch responsive regressions cheaply.