Skip to content
Codeloom
Testing

Visual Regression Testing with Playwright

Catch unintended UI changes automatically by comparing screenshots with Playwright's built-in visual comparison tools.

·7 min read · By Codeloom
Intermediate 10 min read

What you'll learn

  • What visual regression testing is and when you need it
  • Using Playwright toHaveScreenshot for pixel-level comparisons
  • Handling dynamic content, animations, and flakiness
  • Setting up visual tests in CI with consistent rendering

Prerequisites

  • Basic Playwright knowledge
  • A web application with UI components

What Visual Regression Testing Solves

You refactor a CSS file. All your unit tests pass. All your end-to-end tests pass. You deploy. Then a customer reports that the checkout button is now invisible because it has white text on a white background.

Functional tests verify behavior — clicking the button submits the form. Visual regression tests verify appearance — the button looks the way it should. They work by taking screenshots and comparing them against known-good baselines. If the screenshots differ beyond a threshold, the test fails.

Playwright’s Built-in Visual Comparisons

Playwright includes visual comparison out of the box. No third-party tools needed.

Setup

If you do not have Playwright installed:

npm init playwright@latest

Your First Visual Test

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

test("homepage looks correct", async ({ page }) => {
  await page.goto("http://localhost:3000");
  await expect(page).toHaveScreenshot("homepage.png");
});

The first time you run this test, Playwright creates a baseline screenshot at tests/homepage.spec.ts-snapshots/homepage.png. On subsequent runs, it takes a new screenshot and compares it pixel by pixel against the baseline.

Run the test:

npx playwright test

On the first run, it will fail because no baseline exists. Generate baselines with:

npx playwright test --update-snapshots

Comparing Elements, Not Full Pages

Full-page screenshots are useful but can be noisy. For more focused tests, screenshot specific elements:

test("navigation bar appearance", async ({ page }) => {
  await page.goto("http://localhost:3000");
  const navbar = page.locator("nav");
  await expect(navbar).toHaveScreenshot("navbar.png");
});

test("pricing card renders correctly", async ({ page }) => {
  await page.goto("http://localhost:3000/pricing");
  const card = page.locator(".pricing-card").first();
  await expect(card).toHaveScreenshot("pricing-card.png");
});

Element screenshots isolate the component you care about, reducing false positives from unrelated changes elsewhere on the page.

Configuring Comparison Thresholds

Pixel-perfect comparison is often too strict. Anti-aliasing differences between operating systems, font rendering variations, and sub-pixel shifts cause false failures. Configure a threshold:

test("dashboard layout", async ({ page }) => {
  await page.goto("http://localhost:3000/dashboard");
  await expect(page).toHaveScreenshot("dashboard.png", {
    maxDiffPixels: 100,        // allow up to 100 different pixels
  });
});

Alternatively, use a percentage threshold:

await expect(page).toHaveScreenshot("dashboard.png", {
  maxDiffPixelRatio: 0.01,   // allow 1% of pixels to differ
});

You can also set these globally in playwright.config.ts:

// playwright.config.ts
import { defineConfig } from "@playwright/test";

export default defineConfig({
  expect: {
    toHaveScreenshot: {
      maxDiffPixelRatio: 0.01,
    },
  },
  // ...
});

Handling Dynamic Content

Dynamic content like timestamps, avatars, user names, and ads will break visual tests. Handle them by masking or hiding:

Masking Elements

test("profile page without dynamic content", async ({ page }) => {
  await page.goto("http://localhost:3000/profile");
  await expect(page).toHaveScreenshot("profile.png", {
    mask: [
      page.locator(".timestamp"),
      page.locator(".avatar"),
      page.locator(".random-ad"),
    ],
  });
});

Masked elements are replaced with a solid-color box in the screenshot, preventing them from causing false failures.

Replacing Content with JavaScript

For more control, inject CSS or JavaScript before taking the screenshot:

test("dashboard with stable content", async ({ page }) => {
  await page.goto("http://localhost:3000/dashboard");

  // Freeze dynamic text
  await page.evaluate(() => {
    document.querySelectorAll("[data-testid='timestamp']").forEach((el) => {
      el.textContent = "Jan 1, 2026";
    });
  });

  await expect(page).toHaveScreenshot("dashboard-stable.png");
});

Disabling Animations

Animations cause screenshots to capture different frames on different runs. Disable them:

// playwright.config.ts
export default defineConfig({
  use: {
    // Reduce motion for all tests
    contextOptions: {
      reducedMotion: "reduce",
    },
  },
});

You can also inject CSS to disable animations globally:

test.beforeEach(async ({ page }) => {
  await page.addStyleTag({
    content: `
      *, *::before, *::after {
        animation-duration: 0s !important;
        animation-delay: 0s !important;
        transition-duration: 0s !important;
        transition-delay: 0s !important;
      }
    `,
  });
});

Waiting for Stable Rendering

If content loads asynchronously, you need to wait before screenshotting:

test("data table after loading", async ({ page }) => {
  await page.goto("http://localhost:3000/reports");

  // Wait for the loading spinner to disappear
  await page.locator(".loading-spinner").waitFor({ state: "hidden" });

  // Wait for the table to have data
  await page.locator("table tbody tr").first().waitFor();

  // Wait for fonts to load
  await page.evaluate(() => document.fonts.ready);

  await expect(page).toHaveScreenshot("reports-table.png");
});

Playwright’s toHaveScreenshot also has a built-in stability check. It takes two screenshots with a small delay and only proceeds when they match, ensuring animations have settled.

Testing Responsive Layouts

Test the same page at different viewport sizes:

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(`homepage at ${vp.name}`, async ({ page }) => {
    await page.setViewportSize({ width: vp.width, height: vp.height });
    await page.goto("http://localhost:3000");
    await expect(page).toHaveScreenshot(`homepage-${vp.name}.png`);
  });
}

This catches responsive design regressions — a layout that works on desktop but breaks on mobile.

Testing Dark Mode and Themes

test("homepage in dark mode", async ({ page }) => {
  await page.emulateMedia({ colorScheme: "dark" });
  await page.goto("http://localhost:3000");
  await expect(page).toHaveScreenshot("homepage-dark.png");
});

test("homepage in light mode", async ({ page }) => {
  await page.emulateMedia({ colorScheme: "light" });
  await page.goto("http://localhost:3000");
  await expect(page).toHaveScreenshot("homepage-light.png");
});

Visual Tests in CI

Visual tests must run in a consistent environment. Font rendering, anti-aliasing, and sub-pixel positioning differ between macOS, Linux, and Windows. The solution: run visual tests in Docker or on a consistent CI platform.

Using Playwright’s Docker Image

# .github/workflows/visual-tests.yml
name: Visual Regression
on: [pull_request]

jobs:
  visual:
    runs-on: ubuntu-latest
    container:
      image: mcr.microsoft.com/playwright:v1.48.0-jammy
    steps:
      - uses: actions/checkout@v4
      - run: npm ci
      - run: npx playwright test --grep @visual
      - uses: actions/upload-artifact@v4
        if: failure()
        with:
          name: visual-diff-report
          path: test-results/

Generate baselines inside the same Docker image you use in CI. This ensures pixel-perfect consistency.

Updating Baselines

When a visual change is intentional:

# Regenerate all baselines
npx playwright test --update-snapshots

# Regenerate for a specific test
npx playwright test homepage --update-snapshots

Commit the updated baseline screenshots. Reviewers can see the visual diff in the PR.

Reviewing Visual Diffs

When a visual test fails, Playwright generates three images in the test-results directory:

  • homepage-actual.png — what the test captured.
  • homepage-expected.png — the baseline.
  • homepage-diff.png — a highlighted diff showing changed pixels.

The HTML report (npx playwright show-report) displays these side by side, making it easy to decide whether the change is intentional.

Organizing Visual Tests

Keep visual tests separate from functional tests:

tests/
  functional/
    login.spec.ts
    checkout.spec.ts
  visual/
    homepage.visual.spec.ts
    components.visual.spec.ts

Tag them for selective execution:

test("homepage layout @visual", async ({ page }) => {
  // ...
});
# Run only visual tests
npx playwright test --grep @visual

# Skip visual tests during quick feedback loop
npx playwright test --grep-invert @visual

Key Takeaways

Visual regression testing with Playwright catches CSS and layout bugs that functional tests miss. Use element-level screenshots for focused comparisons, mask dynamic content, disable animations, and run tests in Docker for consistency. Start by adding visual tests to your most critical pages — landing pages, checkout flows, and dashboard layouts. Keep baselines in version control and treat screenshot updates as reviewable changes in pull requests.