This guide walks you through building a Playwright visual regression testing suite from scratch. By the end, you will have automated screenshot comparison running in your CI pipeline, catching unintended UI changes before they reach your users. Most teams complete the initial setup in under two hours.

What You'll Build

  • A Playwright test suite that captures baseline screenshots for key pages and components
  • Automated pixel-level diff comparison that fails the build when visual changes exceed your threshold
  • A GitHub Actions workflow that runs visual tests on every pull request
  • A report output that shows side-by-side diffs so your team can approve or reject changes fast

Prerequisites

  • Node.js 20 or later and pnpm 9 (or npm 10)
  • An existing web project with a local dev server (Next.js, Vite, or similar)
  • A GitHub repository with Actions enabled
  • Basic familiarity with writing JavaScript or TypeScript tests

Step 1: Install Playwright and Its Dependencies

Start by adding Playwright to your project. Playwright's built-in snapshot feature handles visual comparison without any third-party diffing library.

pnpm add -D @playwright/test
pnpm exec playwright install --with-deps chromium

Installing only Chromium keeps CI provisioning time low. You can add Firefox and WebKit later if cross-browser visual parity matters to your project.

After installation, verify the setup:

pnpm exec playwright --version

You should see output like Version 1.47.x or later (as of September 2026, Playwright 1.47 is current).

What if the install command fails?

On Linux CI runners, browser installation often fails because of missing system dependencies. Run pnpm exec playwright install-deps chromium first, then retry playwright install chromium. On macOS, ensure Xcode command-line tools are installed.

Step 2: Create the Playwright Configuration File

Create playwright.config.ts at your project root. This file controls how tests run, where snapshots are stored, and what tolerance thresholds apply.

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

export default defineConfig({
  testDir: './tests/visual',
  snapshotDir: './tests/snapshots',
  updateSnapshots: 'none',
  expect: {
    toHaveScreenshot: {
      maxDiffPixelRatio: 0.02,
    },
  },
  use: {
    baseURL: 'http://localhost:3000',
    viewport: { width: 1280, height: 800 },
  },
  projects: [
    {
      name: 'chromium',
      use: { ...devices['Desktop Chrome'] },
    },
  ],
  webServer: {
    command: 'pnpm dev',
    url: 'http://localhost:3000',
    reuseExistingServer: !process.env.CI,
  },
});

The maxDiffPixelRatio of 0.02 means Playwright will accept up to 2% pixel difference. This prevents subpixel rendering differences on different OS environments from generating false failures. For pixel-perfect design systems, lower it to 0.005.

Why does the snapshot directory matter?

Playwright stores baseline images in snapshotDir. These files must be committed to version control. They are the source of truth your CI runner compares against. Without committing them, every CI run regenerates baselines and comparisons never happen.

Step 3: Write Your First Visual Test

Create the directory tests/visual/ and add your first test file.

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

test.describe('Homepage', () => {
  test('full page screenshot matches baseline', async ({ page }) => {
    await page.goto('/');
    await page.waitForLoadState('networkidle');
    await expect(page).toHaveScreenshot('homepage-full.png', {
      fullPage: true,
    });
  });

  test('hero section matches baseline', async ({ page }) => {
    await page.goto('/');
    const hero = page.locator('[data-testid="hero"]');
    await hero.waitFor();
    await expect(hero).toHaveScreenshot('homepage-hero.png');
  });
});

Using waitForLoadState('networkidle') prevents screenshots from capturing half-loaded states. This single habit eliminates most flaky visual tests before they start.

Scoping a screenshot to a specific locator (like the hero section) produces smaller diff areas. When the diff is smaller, failures are more precise and easier to review.

When should you skip full-page screenshots?

Skip them for pages with infinite scroll, live data feeds, or third-party embeds like maps or chat widgets. Those elements change on every render. Test stable, owned components instead and mask dynamic regions using Playwright's mask option.

Step 4: Generate Your Baseline Snapshots

Run this command to create the initial baseline images. You only do this once per new test or whenever you intentionally update the UI.

pnpm exec playwright test --update-snapshots

Playwright will create PNG files inside tests/snapshots/. Commit these files immediately.

git add tests/snapshots/
git commit -m "chore: add visual regression baselines"

A common pitfall here: generating baselines on a macOS machine and then running comparisons on a Linux CI runner. Font rendering and anti-aliasing differ between operating systems, which causes false failures. Generate your baselines inside a Docker container that matches your CI environment, or use a maxDiffPixelRatio value that absorbs OS-level rendering variance.

Step 5: Add Masking for Dynamic Content

Most real pages include content that changes on every visit. Timestamps, analytics scripts, and user avatars all break naive visual tests. Playwright's mask option blacks out those regions before comparison.

test('pricing page matches baseline', async ({ page }) => {
  await page.goto('/pricing');
  await page.waitForLoadState('networkidle');
  await expect(page).toHaveScreenshot('pricing.png', {
    fullPage: true,
    mask: [
      page.locator('[data-testid="live-user-count"]'),
      page.locator('.cookie-banner'),
    ],
  });
});

Add data-testid attributes to any element that should be masked. This keeps your tests explicit and readable.

Step 6: Set Up GitHub Actions for CI

Create .github/workflows/visual-tests.yml. This workflow runs your visual tests on every pull request.

name: Visual Regression Tests

on:
  pull_request:
    branches: [main, develop]

jobs:
  visual-test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4

      - uses: pnpm/action-setup@v4
        with:
          version: 9

      - uses: actions/setup-node@v4
        with:
          node-version: '20'
          cache: 'pnpm'

      - name: Install dependencies
        run: pnpm install

      - name: Install Playwright browsers
        run: pnpm exec playwright install --with-deps chromium

      - name: Run visual regression tests
        run: pnpm exec playwright test

      - name: Upload test report
        uses: actions/upload-artifact@v4
        if: failure()
        with:
          name: playwright-report
          path: playwright-report/
          retention-days: 7

The report upload step only runs on failure. This keeps passing build artifacts clean while giving your team a downloadable diff report whenever something breaks.

Why upload the report only on failure?

Playwright generates HTML reports with side-by-side image diffs. On a failing build, a developer can download the artifact, open index.html, and see exactly which pixels changed. This turns a vague CI failure into a five-second diagnosis.

Step 7: Integrate the Update Workflow Into Your Team Process

Visual regression tests only help if your team knows how to update baselines intentionally. Without a clear process, developers either disable the tests or commit broken baselines.

Establish these three rules:

  1. Run pnpm exec playwright test --update-snapshots locally when a UI change is intentional.
  2. Commit the updated snapshot files in the same commit as the UI change.
  3. Include a note in the PR description explaining why baselines were updated.

Some teams at agencies like Lenka Studio take this further by requiring a short screen recording showing the before and after state whenever baselines change. This creates an audit trail that helps in design reviews.

You can also automate baseline updates with a separate workflow triggered by a PR label like update-snapshots. This keeps the update process intentional without requiring every developer to run the command locally.

Step 8: Test Responsive Breakpoints

Most visual regressions happen at breakpoints. Add a parameterised test that covers your key viewports.

const viewports = [
  { name: 'mobile', width: 375, height: 812 },
  { name: 'tablet', width: 768, height: 1024 },
  { name: 'desktop', width: 1440, height: 900 },
];

for (const vp of viewports) {
  test(`navigation matches baseline at ${vp.name}`, async ({ page }) => {
    await page.setViewportSize({ width: vp.width, height: vp.height });
    await page.goto('/');
    await page.waitForLoadState('networkidle');
    const nav = page.locator('[data-testid="main-nav"]');
    await expect(nav).toHaveScreenshot(`nav-${vp.name}.png`);
  });
}

This pattern catches the most common bug in responsive design: a layout that looks correct on desktop but collapses incorrectly at 768px.

Step 9: Measure and Monitor Test Performance

A slow visual test suite blocks developers and gets disabled. Keep your suite fast by following these benchmarks.

  • Target under 3 minutes for the full suite on CI
  • Limit full-page screenshots to 5 to 8 key pages
  • Prefer component-level tests over page-level tests (they run faster and produce smaller diffs)
  • Use Playwright's --shard flag to split tests across parallel CI runners
# Split across 4 runners
pnpm exec playwright test --shard=1/4

Sharding reduces CI time by roughly 60 to 70% on suites with more than 30 screenshot tests. Each shard runs independently, and GitHub Actions can run them in parallel jobs.

Teams that work with Lenka Studio on design system projects often discover that visual regression tests cut design QA review time by more than half, because engineers catch regressions before the design team ever sees them.

Frequently Asked Questions

Does this work with Storybook component tests?

Yes. Playwright can test Storybook stories by pointing baseURL at your Storybook server. Use the @storybook/test-runner package if you want tighter integration, or use plain Playwright with direct story URLs for simpler setups.

How is Playwright visual testing different from Percy or Chromatic?

Percy and Chromatic are paid SaaS services that manage baselines in the cloud and offer approval workflows. Playwright's built-in snapshot testing is free and self-hosted, but you manage baseline storage yourself via version control. For teams that want a free, low-dependency option, Playwright is the better starting point.

What if tests keep failing on CI but pass locally?

The most common cause is OS-level font rendering differences between macOS (local) and Ubuntu (CI). Generate your baselines inside a Docker container that uses the same base image as your CI runner. Alternatively, raise maxDiffPixelRatio to 0.03 to absorb the variance.

Can I run visual tests against a staging URL instead of localhost?

Yes. Set baseURL in your config to your staging URL and remove the webServer block. Pass the URL as an environment variable in CI to keep the config flexible across environments.

How many visual tests should a typical project have?

A practical starting point is 10 to 20 tests covering your most visited pages and your core UI components. Start small, keep the suite fast, and add tests when a visual bug is reported in production. Covering the bug with a test prevents it from returning.

Next Steps

You now have a working Playwright visual regression suite with CI integration and a clear team workflow. From here, consider adding Playwright component tests for your design system, connecting the report artifact to a Slack notification, or extending coverage to your mobile breakpoints.

If your team is building a design system or component library and wants to embed visual testing into the delivery process from day one, the engineers at Lenka Studio can help you scope and implement the right testing strategy for your stack. Get in touch to talk through your project.