All posts

15 Days of Playwright - Day 12: Visual Testing and Screenshots

Catch regressions that functional tests miss. Learn how to use Playwright's built-in screenshot comparison to lock in your UI's appearance and get alerted the moment it drifts.

Jhonatas Matos

Jhonatas Matos

15 Days of Playwright - Day 12: Visual Testing and Screenshots

Functional tests verify that a button works. Visual tests verify that the button looks right. Both matter — and most teams only do one.

A functional test can pass even when a CSS change turns your checkout button invisible, a font migration breaks your typography, or a third-party widget overlaps your navigation. Visual testing catches exactly those regressions that functional assertions can't see.

Today we'll cover Playwright's built-in screenshot comparison engine — no extra dependencies required.

How Visual Testing Works in Playwright

Playwright's toHaveScreenshot() assertion works in two phases:

  1. Baseline generation — the first time the assertion runs, Playwright saves a reference image (.png file) to your snapshot directory.
  2. Comparison — on every subsequent run, Playwright takes a new screenshot and compares it pixel-by-pixel against the baseline. If the difference exceeds a configurable threshold, the test fails.

No external service needed. The baseline images live in your repository alongside your tests.

Your First Visual Assertion

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

test('homepage looks correct', async ({ page }) => {
  await page.goto('https://bugbank.netlify.app');
  
  // Full-page screenshot comparison
  await expect(page).toHaveScreenshot('homepage.png');
});

Run this once to generate the baseline:

npx playwright test --update-snapshots

Run it again to compare:

npx playwright test

The baseline is saved to __snapshots__/ (or your configured snapshotDir) and must be committed to version control.

Element-Level Screenshots

You don't have to capture the whole page. Narrow the comparison to a specific component:

test('login card appearance', async ({ page }) => {
  await page.goto('https://bugbank.netlify.app');
  
  const loginCard = page.locator('.login-card');
  await expect(loginCard).toHaveScreenshot('login-card.png');
});

Element-scoped screenshots are more stable than full-page ones because they ignore changes in unrelated parts of the UI.

Configuring Thresholds

Pixel-perfect comparison is too strict for most applications — anti-aliasing, sub-pixel rendering, and font hinting cause tiny differences across OS and GPU combinations. Set a tolerance threshold:

test('hero section visual', async ({ page }) => {
  await page.goto('/');
  
  await expect(page).toHaveScreenshot('hero.png', {
    maxDiffPixels: 100,      // Allow up to 100 pixels to differ
    // OR
    maxDiffPixelRatio: 0.01, // Allow up to 1% of pixels to differ
    threshold: 0.2,          // Per-pixel color difference (0–1)
  });
});

Recommended starting point: maxDiffPixelRatio: 0.01 (1%) for full-page, maxDiffPixels: 50 for element-scoped.

Global Visual Configuration

Set defaults in playwright.config.js so you don't repeat them in every test:

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

export default defineConfig({
  expect: {
    toHaveScreenshot: {
      maxDiffPixelRatio: 0.01,
      threshold: 0.2,
    },
  },
  snapshotDir: '__snapshots__',
});

Masking Dynamic Content

Visual tests fail on content that changes between runs: timestamps, ads, animated elements, user-specific data. Mask those regions before comparing:

test('dashboard with masked dynamic content', async ({ page }) => {
  await page.goto('/dashboard');
  
  await expect(page).toHaveScreenshot('dashboard.png', {
    mask: [
      page.locator('.last-login-timestamp'),
      page.locator('.live-chart'),
      page.locator('.user-avatar'),
    ],
  });
});

Masked regions are replaced with a solid color in both the baseline and comparison, so they never cause failures.

Practical BugBank Examples

Scenario 1: Register Form Appearance

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

test.describe('BugBank visual tests', () => {
  test('register form renders correctly', async ({ page }) => {
    await page.goto('https://bugbank.netlify.app');
    await page.getByText('Registrar').click();
    
    // Wait for the form to be fully loaded
    await expect(page.getByRole('button', { name: 'Cadastrar' })).toBeVisible();
    
    // Capture just the registration card
    const registerCard = page.locator('.card__register');
    await expect(registerCard).toHaveScreenshot('register-card.png');
  });

  test('success modal appearance', async ({ page }) => {
    await page.goto('https://bugbank.netlify.app');
    await page.getByText('Registrar').click();

    await page.getByPlaceholder('Informe seu nome').fill('Visual Test User');
    await page.getByPlaceholder('Informe seu e-mail').fill('visual@test.com');
    await page.getByPlaceholder('Informe sua senha').fill('password123');
    await page.getByPlaceholder('Informe a confirmação da senha').fill('password123');
    await page.getByRole('button', { name: 'Cadastrar' }).click();

    // Modal appears — capture it
    const modal = page.locator('#modal');
    await expect(modal).toBeVisible();
    await expect(modal).toHaveScreenshot('success-modal.png', {
      mask: [page.locator('#modalText')], // account number is dynamic
    });
  });
});

Scenario 2: Responsive Layout Testing

test.describe('Responsive visual tests', () => {
  const viewports = [
    { name: 'desktop', width: 1280, height: 720 },
    { name: 'tablet', width: 768, height: 1024 },
    { name: 'mobile', width: 390, height: 844 },
  ];

  for (const viewport of viewports) {
    test(`homepage at ${viewport.name}`, async ({ page }) => {
      await page.setViewportSize({ width: viewport.width, height: viewport.height });
      await page.goto('https://bugbank.netlify.app');
      
      await expect(page).toHaveScreenshot(`homepage-${viewport.name}.png`);
    });
  }
});

Handling Baseline Updates

When a UI change is intentional (a redesign, a rebrand), update the baselines:

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

# Update snapshots for a specific test file
npx playwright test tests/visual.spec.js --update-snapshots

After updating, commit the new baseline images. Reviewers can diff the .png files in GitHub to approve visual changes — making design reviews part of the code review workflow.

Cross-Browser Visual Testing

Baselines are per-browser by default because rendering differs between Chromium, Firefox, and WebKit. Configure multiple projects in playwright.config.js:

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

export default defineConfig({
  projects: [
    { name: 'chromium', use: { ...devices['Desktop Chrome'] } },
    { name: 'firefox', use: { ...devices['Desktop Firefox'] } },
    { name: 'webkit', use: { ...devices['Desktop Safari'] } },
  ],
});

Playwright stores separate baselines for each project: homepage-chromium.png, homepage-firefox.png, etc.

Best Practices

1. Wait for Animations to Complete Before Capturing

test('animated hero', async ({ page }) => {
  await page.goto('/');
  
  // Disable CSS animations to get stable screenshots
  await page.addStyleTag({
    content: `
      *, *::before, *::after {
        animation-duration: 0s !important;
        transition-duration: 0s !important;
      }
    `
  });
  
  await expect(page).toHaveScreenshot('hero.png');
});

2. Use animations: 'disabled' in Config

export default defineConfig({
  use: {
    screenshot: { animations: 'disabled' },
  },
});

This disables CSS animations globally for all screenshot captures — no inline style injection needed.

3. Commit Baselines to Version Control

Baselines are source of truth. They belong in the repo, not a CI artifact store. Reviewers should approve visual changes the same way they approve code changes.

4. Run Visual Tests Separately from Functional Tests

Visual tests are inherently environment-dependent. Tag them and run in a dedicated project:

export default defineConfig({
  projects: [
    { name: 'functional', testMatch: '**/*.spec.js' },
    { name: 'visual', testMatch: '**/*.visual.js', use: { ...devices['Desktop Chrome'] } },
  ],
});
Link to GitHub projectGitHub Octocat

Conclusion

Visual testing fills the gap that functional tests leave behind. With Playwright's built-in screenshot comparison you get:

  • Baseline-based comparison — no external service, baselines live in your repo
  • Element-scoped captures — test components in isolation, not entire pages
  • Dynamic content masking — silence the noise, catch the real regressions
  • Cross-browser visual coverage — know exactly how your UI looks in every target browser

Start with one visual test per critical page. You'll catch your first unintended CSS regression within a week.


Thank you for reading and see you in the next lesson! ☕💜