Testing

Playwright Visual Regression Testing: Setup and Common Pitfalls

Learn how to set up visual regression testing in Playwright, create screenshot baselines, update snapshots safely, and avoid common sources of flaky visual tests.

Playwright Visual Regression Testing: Setup and Common Pitfalls

Playwright makes visual regression testing feel almost too easy.

You write one assertion:

await expect(page).toHaveScreenshot();

The first run creates a baseline screenshot. Later runs compare the current page against that baseline. If the UI changes, the test fails and shows a diff.

That simplicity is the good news.

The bad news is that screenshot tests are only useful when the page is deterministic. If the screenshot includes animations, timestamps, random data, remote images, ads, loading states, or platform-specific font rendering, the test can fail even when the product is fine.

This guide covers how to set up Playwright visual regression testing and the pitfalls that usually make screenshot tests noisy.

What Playwright Visual Regression Testing Does

Playwright visual regression testing compares a new screenshot against a stored reference screenshot.

The workflow looks like this:

Run test first time
      |
      v
Create baseline screenshot
      |
      v
Commit baseline
      |
      v
Run test later
      |
      v
Compare new screenshot to baseline
      |
      v
Pass, fail, or update baseline

Playwright’s visual comparisons documentation describes this using toHaveScreenshot(), which creates reference screenshots and compares later test runs against them.

For the broader concept, see what is visual regression testing?. This article focuses on the Playwright setup and the practical traps.

Basic Setup

If Playwright is not installed yet, initialize it in your project:

npm init playwright@latest

Then create a test like this:

import { test, expect } from '@playwright/test';

test('home page visual state', async ({ page }) => {
  await page.goto('/');
  await expect(page).toHaveScreenshot('home-page.png');
});

Run the test:

npx playwright test

On the first run, Playwright will not find a baseline screenshot. It will write the actual screenshot so you can review and commit it.

On later runs, Playwright captures the page again and compares it to the stored baseline.

Where Baselines Are Stored

By default, Playwright stores screenshot snapshots beside the test file in a folder named after that test file.

For example:

tests/home.spec.ts
tests/home.spec.ts-snapshots/home-page-chromium-win32.png

The exact file name includes the snapshot name plus project or platform information. That is intentional because screenshots can differ by browser engine and operating system.

Commit these baseline files to version control. They are part of the test expectation, just like an inline assertion.

First Rule: Generate Baselines in the Same Environment

Screenshots are sensitive to the environment.

The same page can render slightly differently depending on:

  • Operating system
  • Browser engine
  • Browser version
  • Font availability
  • Headless vs headed mode
  • GPU and hardware differences
  • Device scale factor
  • Power settings

Playwright’s docs warn that browser rendering can vary across host OS, versions, settings, hardware, power source, and headless mode. The practical takeaway is simple:

Generate and compare screenshots in the same environment.

If CI runs on Linux, generate baselines on Linux. If you need snapshots for Chromium, Firefox, and WebKit, treat those as separate baselines.

Configure Projects for Viewports

Visual tests are most useful when they cover the viewports users actually use.

Example playwright.config.ts:

import { defineConfig, devices } from '@playwright/test';

export default defineConfig({
  testDir: './tests',
  use: {
    baseURL: 'http://localhost:3000',
  },
  projects: [
    {
      name: 'desktop-chromium',
      use: {
        ...devices['Desktop Chrome'],
        viewport: { width: 1440, height: 900 },
      },
    },
    {
      name: 'mobile-chromium',
      use: {
        ...devices['Pixel 5'],
      },
    },
  ],
});

This creates separate visual expectations for desktop and mobile. That matters because many UI regressions only appear at one breakpoint.

For layout-sensitive issues, this pairs naturally with the CSS box model, where small spacing changes can produce large visual effects.

Page Screenshot vs Locator Screenshot

Playwright supports page-level and element-level screenshots.

Page-level:

await expect(page).toHaveScreenshot('dashboard.png');

Locator-level:

await expect(page.getByRole('navigation')).toHaveScreenshot('nav.png');

Use page screenshots when you want to catch full layout regressions.

Use locator screenshots when you want focused, stable checks for a component or section.

Full page:
  catches layout interactions
  noisier

Locator:
  catches component regressions
  usually more stable

A good suite often uses both: a few full-page tests for major screens and more focused screenshots for high-value components.

Naming Screenshots

Always name important screenshots explicitly.

Prefer:

await expect(page).toHaveScreenshot('checkout-summary-empty.png');

over:

await expect(page).toHaveScreenshot();

Auto-generated names work, but explicit names are easier to review in pull requests and easier to understand months later.

Good names describe the state:

dashboard-empty.png
dashboard-loaded.png
checkout-payment-error.png
settings-dark-mode.png
mobile-nav-open.png

Visual tests are about UI states, so name the state.

Updating Snapshots

When a visual change is intentional, update the baseline:

npx playwright test --update-snapshots

or:

npx playwright test -u

That command should not be treated as “make the test pass.”

It means:

I reviewed the visual diff,
and this new screenshot is now the expected UI.

Baseline updates should be reviewed like code. If your team uses pull requests, snapshot changes should be visible in the review.

Set Screenshot Thresholds Carefully

Playwright uses image comparison under the hood and lets you configure tolerance.

Example:

await expect(page).toHaveScreenshot('home.png', {
  maxDiffPixels: 100,
});

Or globally:

import { defineConfig } from '@playwright/test';

export default defineConfig({
  expect: {
    toHaveScreenshot: {
      maxDiffPixels: 100,
    },
  },
});

Thresholds help reduce noise from tiny rendering differences. But they can also hide real bugs.

Use the smallest threshold that removes harmless noise. If you need huge tolerances, the test is probably too broad or too unstable.

Disable or Control Animations

Animations are a common cause of flaky screenshots.

Playwright’s screenshot assertion disables animations by default, and the API docs for toHaveScreenshot() describe how finite and infinite animations are handled.

Still, you may need extra control for:

  • CSS animations
  • Skeleton loaders
  • Video backgrounds
  • Canvas animations
  • Third-party widgets
  • Progress bars

A simple screenshot stylesheet can help:

*,
*::before,
*::after {
  animation: none !important;
  transition: none !important;
}

.current-time,
.ad-slot,
.live-chat-widget {
  visibility: hidden !important;
}

Then configure it:

import { defineConfig } from '@playwright/test';

export default defineConfig({
  expect: {
    toHaveScreenshot: {
      stylePath: './tests/screenshot.css',
    },
  },
});

Playwright supports stylePath specifically to make screenshots more deterministic.

Mask Dynamic Elements

Sometimes hiding a dynamic element is better than removing it from the page.

For example, you may want to ignore:

  • User avatars
  • Current timestamps
  • Random IDs
  • Live stock prices
  • Map tiles
  • Ads
  • Third-party embeds

Example:

await expect(page).toHaveScreenshot('account-page.png', {
  mask: [
    page.getByTestId('user-avatar'),
    page.getByTestId('last-login-time'),
  ],
});

Masking lets the rest of the page remain visually covered while ignoring known volatile regions.

Use masking sparingly. If you mask half the page, the test is no longer telling you much.

Wait for the Right State

Playwright auto-waits for actions and assertions, but visual tests still need the page to reach the intended state.

Do this:

await page.goto('/dashboard');
await expect(page.getByRole('heading', { name: 'Dashboard' })).toBeVisible();
await expect(page.getByTestId('loading-skeleton')).toBeHidden();
await expect(page).toHaveScreenshot('dashboard-loaded.png');

Avoid this:

await page.goto('/dashboard');
await page.waitForTimeout(2000);
await expect(page).toHaveScreenshot('dashboard-loaded.png');

Waiting for time is brittle. Waiting for state is better.

This connects to Playwright’s broader strengths around auto-waiting, covered in what is Playwright?.

Use Deterministic Test Data

Visual tests should not depend on production-like randomness.

Control:

  • Names
  • Dates
  • Counts
  • Feature flags
  • API responses
  • User permissions
  • Images
  • Empty states
  • Error states

Playwright can intercept network requests, or you can run against a local mock API. The point is to make the screen predictable.

Example:

await page.route('**/api/subscription', async route => {
  await route.fulfill({
    json: {
      plan: 'Pro',
      status: 'active',
      seats: { used: 8, included: 10 },
    },
  });
});

For the broader workflow, see how to mock an API before the backend exists and what is a mock server?.

Freeze Dates and Time

Dates are quiet screenshot breakers.

This fails eventually:

Today is August 16

Tomorrow it says:

Today is August 17

The UI may be correct, but the screenshot changed.

Use a fixed clock where possible:

test.beforeEach(async ({ page }) => {
  await page.clock.setFixedTime(new Date('2026-08-16T10:00:00Z'));
});

If your app does not support Playwright’s clock controls cleanly, inject test data that already contains stable formatted dates.

Screenshot Specific States

Do not only test the happy path.

Useful visual states include:

  • Empty state
  • Loading complete
  • Validation error
  • Permission denied
  • Mobile menu open
  • Modal open
  • Toast visible
  • Dark mode
  • Long text
  • Many rows
  • One row
  • No rows

Example:

test('checkout payment error visual state', async ({ page }) => {
  await page.goto('/checkout');
  await page.getByRole('button', { name: 'Pay now' }).click();
  await expect(page.getByText('The card was declined')).toBeVisible();
  await expect(page).toHaveScreenshot('checkout-payment-error.png');
});

Visual bugs often hide in states that developers do not look at every day.

If the page has explicit lifecycle states, what is a state machine? is a useful mental model for deciding which screenshots are worth keeping.

Avoid Overusing Full-Page Screenshots

Full-page screenshots are tempting because they appear comprehensive.

They are also noisy.

They can fail because of:

  • Footer content
  • Long lists
  • Lazy-loaded images
  • Cookie banners
  • Third-party embeds
  • Scroll-position differences
  • Ads
  • Content far below the area under test

Use full-page screenshots for pages where the whole layout matters. For most tests, prefer a viewport screenshot or locator screenshot around the important region.

Playwright also has a lower-level screenshots API for manually capturing pages, full pages, buffers, and individual elements. For regression assertions, toHaveScreenshot() is usually the better starting point because it manages baseline comparison.

Keep Visual Tests Focused

A visual test should have a reason to exist.

Good targets:

  • Critical checkout screens
  • Authentication flows
  • Design system components
  • Pricing pages
  • High-traffic landing pages
  • Dense dashboards
  • Complex responsive layouts
  • Components with frequent CSS changes

Poor targets:

  • Highly dynamic feeds
  • Pages dominated by third-party content
  • Screens with no meaningful visual risk
  • Throwaway prototypes
  • Internal pages that change constantly

If every page gets a screenshot, reviewers eventually stop caring about diffs. A smaller, better-chosen suite is easier to trust.

CI Setup

In CI, run visual tests in a consistent environment.

Typical flow:

Install dependencies
Install Playwright browsers
Start app
Run Playwright tests
Upload report and diffs
Review baseline changes

Example commands:

npm ci
npx playwright install --with-deps
npx playwright test

If baselines are generated on developer laptops but compared in Linux CI, expect noise. Prefer generating and updating baselines in the same environment CI uses.

Some teams handle this by updating snapshots only through CI artifacts or containerized local runs.

Reviewing Diffs

When a screenshot test fails, look at three images:

  • Expected baseline
  • Actual screenshot
  • Diff image

Ask:

  • Is the change intentional?
  • Is the page in the correct state?
  • Is the diff caused by dynamic data?
  • Did the environment change?
  • Did the test capture too much?
  • Should the baseline be updated?
  • Should the UI be fixed?

The worst habit is updating snapshots blindly. That turns visual regression testing into visual rubber-stamping.

Common Pitfall: Missing Fonts

Font differences are one of the most common causes of visual diffs.

If a custom font loads locally but not in CI, text will reflow. If CI uses different system fonts, line breaks may change. If font loading races with screenshot capture, the same test may pass and fail intermittently.

Fixes include:

  • Bundle fonts with the app
  • Ensure CI can load font assets
  • Wait for font readiness if needed
  • Use the same OS for baseline generation and comparison
  • Avoid relying on platform-specific fallback fonts

Small font differences can produce large screenshot diffs because text affects layout.

Common Pitfall: Loading States

A screenshot of a half-loaded page is not a visual baseline. It is a coin toss.

Avoid:

await page.goto('/dashboard');
await expect(page).toHaveScreenshot('dashboard.png');

Prefer:

await page.goto('/dashboard');
await expect(page.getByTestId('dashboard-loaded')).toBeVisible();
await expect(page).toHaveScreenshot('dashboard.png');

Make the intended state explicit.

Common Pitfall: Random Data

Random data makes tests look realistic but unstable.

This includes:

  • Random user names
  • Random colors
  • Random avatars
  • Random ordering
  • Random IDs shown in the UI
  • Random generated content

Use seeded data or fixed fixtures. Realism is useful only when the output is predictable.

Common Pitfall: Too Much Tolerance

Tolerances are useful for tiny rendering differences. They are dangerous when used to silence real changes.

This is suspicious:

await expect(page).toHaveScreenshot('checkout.png', {
  maxDiffPixels: 10000,
});

Large tolerances can allow meaningful layout bugs through.

When a test needs a huge threshold, first ask whether the screenshot should be smaller, dynamic areas should be masked, or the state should be made more deterministic.

Common Pitfall: Snapshot Sprawl

Screenshot suites can grow quickly.

If every variation becomes a baseline, reviews become slow and noisy. Organize snapshots around meaningful states, not every possible combination.

A practical starting set:

One critical desktop screenshot
One critical mobile screenshot
One empty state
One error state
One modal or menu state

Expand only when regressions or design risk justify it.

Visual Tests Do Not Replace Functional Tests

A screenshot can prove a button is visible. It does not prove the button submits the right payload.

Keep visual tests alongside:

  • Functional assertions
  • API tests
  • Accessibility checks
  • Contract tests
  • Unit tests
  • End-to-end flows

For service boundaries, contract testing vs integration testing covers a different kind of confidence. Visual tests catch what rendered. They do not validate every behavior behind the screen.

If you are building Playwright visual tests, try these topics:

For external references, start with Playwright’s visual comparisons, toHaveScreenshot() API, and screenshots API.

Frequently Asked Questions

How do I do visual regression testing in Playwright? Use await expect(page).toHaveScreenshot('name.png') or await expect(locator).toHaveScreenshot('name.png'). The first run creates a baseline, and later runs compare against it.

How do I update Playwright screenshots? Run npx playwright test --update-snapshots or npx playwright test -u. Review the visual diff before committing updated baselines.

Should Playwright screenshot baselines be committed? Yes. Baseline screenshots are test expectations and should be reviewed and committed like other test files.

Why are my Playwright visual tests flaky? Common causes include animations, loading states, timestamps, random data, remote images, missing fonts, environment differences, and screenshots captured before the UI reaches the intended state.

Should I use full-page screenshots or component screenshots? Use full-page screenshots for major layout checks and locator screenshots for focused component states. Locator screenshots are often more stable.

Conclusion

Playwright makes visual regression testing straightforward with toHaveScreenshot(), but stable screenshot tests require discipline. Generate baselines in the same environment that compares them, name screenshots clearly, wait for the right UI state, control dynamic data, and review baseline updates carefully.

The strongest Playwright visual suites are not the largest ones. They are the ones that capture meaningful, deterministic UI states and produce diffs the team actually trusts.

Written by the Workshelve team, who write practical explainers on data integrity, networking, and developer tooling.