← Blog

"Playwright Series #8: Visual Testing — Screenshots and Pixel-Perfect Comparisons"

Catch CSS regressions that functional tests completely miss. Learn Playwright's built-in screenshot comparison, masking dynamic content, testing across viewports, and managing baselines in CI.

reading now
views
comments

Series Navigation

Part 7: Authentication — Sessions, Storage State and Multi-User Testing

Part 9: Parallel Execution, Sharding and Reporting


What Visual Testing Catches

Functional tests verify behaviour. Visual tests verify appearance. They catch an entirely different class of regressions:

  • A CSS refactor that shifted the layout by 4px
  • A font-weight change that made headings lighter
  • A z-index bug where a dropdown appears behind a modal
  • A colour token update that changed button colours in the wrong theme
  • A responsive breakpoint that broke the mobile nav

None of these break functional assertions. All of them are visible to users.


Built-in Screenshot Comparison

Playwright has screenshot comparison built in — no extra libraries needed.

test('homepage looks correct', async ({ page }) => {
  await page.goto('/');
  await expect(page).toHaveScreenshot('homepage.png');
});

First run: Playwright generates the baseline screenshot and saves it to tests/screenshots/ (or wherever configured). The test passes.

Subsequent runs: Playwright compares the new screenshot against the baseline pixel by pixel. If they differ beyond a threshold, the test fails and shows you a diff image.


Element-Level Screenshots

Capture a specific component rather than the full page:

test('product card renders correctly', async ({ page }) => {
  await page.goto('/shop');

  const card = page.getByTestId('product-card').first();
  await expect(card).toHaveScreenshot('product-card.png');
});

test('navigation menu is correct', async ({ page }) => {
  await page.goto('/');
  await expect(page.getByRole('navigation')).toHaveScreenshot('nav.png');
});

Element-level screenshots are more focused and less noisy than full-page captures.


Masking Dynamic Content

Dynamic content — dates, user names, ad banners, animated elements — causes false failures. Mask them:

test('dashboard with masked dynamic content', async ({ page }) => {
  await page.goto('/dashboard');

  await expect(page).toHaveScreenshot('dashboard.png', {
    mask: [
      page.getByTestId('current-date'),      // date changes daily
      page.getByTestId('user-greeting'),      // user-specific text
      page.getByTestId('live-price-ticker'),  // live data
      page.getByRole('img', { name: /avatar/ }), // profile photos
    ]
  });
});

Masked areas are filled with a solid colour in the comparison, so dynamic content doesn't trigger false failures.


Configuring Tolerance

Pixel-perfect comparison is too strict for most teams — antialiasing and rendering differences across OS/GPU can cause minor pixel diffs. Set a threshold:

// playwright.config.ts
export default defineConfig({
  expect: {
    toHaveScreenshot: {
      // Allow up to 0.2% of pixels to differ
      maxDiffPixelRatio: 0.002,

      // Or allow up to 100 pixels to differ
      maxDiffPixels: 100,

      // Threshold per-pixel colour difference (0–1)
      threshold: 0.2,
    }
  }
});

Override per-assertion:

await expect(page).toHaveScreenshot('hero.png', {
  maxDiffPixelRatio: 0.01 // 1% tolerance for this specific screenshot
});

Viewport and Responsive Testing

Test your layout at multiple screen sizes:

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

const viewports = [
  { name: 'mobile',  width: 390,  height: 844  }, // iPhone 14
  { name: 'tablet',  width: 768,  height: 1024 }, // iPad
  { name: 'desktop', width: 1440, height: 900  }, // standard desktop
];

for (const viewport of viewports) {
  test(`homepage layout on ${viewport.name}`, async ({ page }) => {
    await page.setViewportSize({ width: viewport.width, height: viewport.height });
    await page.goto('/');

    await expect(page).toHaveScreenshot(`homepage-${viewport.name}.png`);
  });
}

Or use Playwright's built-in device emulation:

// playwright.config.ts
projects: [
  { name: 'Desktop Chrome',  use: { ...devices['Desktop Chrome'] } },
  { name: 'Mobile Safari',   use: { ...devices['iPhone 14'] } },
  { name: 'Tablet Android',  use: { ...devices['Galaxy Tab S4'] } },
]

Each project runs all tests with that device's viewport, user agent and touch settings.


Waiting Before Capturing

Animations and lazy-loaded images can cause flaky screenshots. Wait for stability first:

test('animated hero section', async ({ page }) => {
  await page.goto('/');

  // Wait for CSS animations to finish
  await page.waitForFunction(() =>
    document.querySelectorAll('.animating').length === 0
  );

  // Or wait for all images to load
  await page.waitForFunction(() => {
    const images = document.querySelectorAll('img');
    return Array.from(images).every(img => img.complete && img.naturalWidth > 0);
  });

  await expect(page).toHaveScreenshot('hero.png');
});

Disable animations entirely in your test environment for more stable captures:

/* styles for test environment */
*, *::before, *::after {
  animation-duration: 0s !important;
  transition-duration: 0s !important;
}

Apply via CSS class or environment variable in your app:

// In global-setup or a beforeAll
await page.addStyleTag({
  content: `
    *, *::before, *::after {
      animation-duration: 0.001ms !important;
      transition-duration: 0.001ms !important;
    }
  `
});

Dark Mode Testing

Test both themes:

test.describe('dark mode', () => {
  test.use({ colorScheme: 'dark' });

  test('dashboard in dark mode', async ({ page }) => {
    await page.goto('/dashboard');
    await expect(page).toHaveScreenshot('dashboard-dark.png');
  });
});

test.describe('light mode', () => {
  test.use({ colorScheme: 'light' });

  test('dashboard in light mode', async ({ page }) => {
    await page.goto('/dashboard');
    await expect(page).toHaveScreenshot('dashboard-light.png');
  });
});

Updating Baselines

When a visual change is intentional (new design, approved redesign), update the baselines:

# Update all screenshots
npx playwright test --update-snapshots

# Update screenshots for a specific file
npx playwright test tests/homepage.spec.ts --update-snapshots

# Update a specific test
npx playwright test --update-snapshots --grep "homepage looks correct"

Always commit updated baseline images alongside the code change so the team can review the visual diff in the PR.


Storing Screenshots in CI

Configure where screenshots are stored:

// playwright.config.ts
export default defineConfig({
  snapshotDir: './tests/screenshots', // baseline location
  snapshotPathTemplate: '{snapshotDir}/{testFilePath}/{arg}{ext}',
});

In CI, always run with --update-snapshots disabled (the default). If baselines don't exist in the repository, the test will fail — treat this as a reminder to commit baselines.


Full-Page Screenshots (Beyond the Viewport)

test('full page capture', async ({ page }) => {
  await page.goto('/long-landing-page');

  await expect(page).toHaveScreenshot('full-page.png', {
    fullPage: true // captures the entire scrollable page
  });
});

What's Next

In Part 9 we cover parallel execution, test sharding and reporting — how to dramatically speed up your test suite across multiple CPU cores and multiple machines, and how to produce useful reports your team will actually read.

Discussion

Loading...

Leave a Comment

All comments are reviewed before appearing. No links please.

0 / 1000