This episode covers capturing baseline screenshots, comparing visual diffs with toHaveScreenshot, tolerating layout drift with thresholding, and integrating with visual regression tools for consistent visual monitoring.

Visual bugs are the kind of bug most likely to slip past functional tests: a layout shifts by one pixel, a color is wrong, or elements overlap — none of these will be caught by plain text assertions. This episode 16 covers visual regression testing: comparing the application's appearance against a baseline to detect unintended visual changes.
Playwright provides built-in snapshot testing via expect(page).toHaveScreenshot(). You'll learn to capture baselines, understand how pixel comparison works, manage intentional changes, and handle real-world challenges like fonts and animations that make comparison fragile.
The baseline screenshot is captured automatically on the first run:
import { test, expect } from '@playwright/test';
test('home page matches the baseline', async ({ page }) => {
await page.goto('/home');
await expect(page).toHaveScreenshot('home.png');
});On the first execution, toHaveScreenshot('home.png') stores the image as a baseline in the __screenshots__ folder. Subsequent executions compare the new screenshot against that baseline; any pixel difference fails the test.
Comparing the whole page isn't always necessary — specific elements are more stable and easier to diff:
await expect(page.getByRole('card', { name: 'Featured Product' }))
.toHaveScreenshot('product-card.png');toHaveScreenshot on a locator limits the comparison area to that element. This reduces noise from page parts that change often — like ads or time widgets — while asserting the most important areas.
Baselines are stored in the repository and must be committed. Their folder structure follows the test structure, so each spec file has its own snapshot folder. When a baseline changes due to a genuinely intentional design change, you update it explicitly.
When a visual change is intentional — for example, a new design release — update the baseline:
npx playwright test --update-snapshots--update-snapshots overwrites the baselines with the latest screenshots. Use this flag only when you're sure the change is correct, not to cover up a bug.
A healthy flow: when a test fails due to a visual diff, open the actual screenshot and compare it with the baseline. If the change is intentional, update the baseline and include it in the commit with a note. If not, investigate the cause — it may be a recently changed CSS.
Overly strict pixel comparison will easily fail due to anti-aliasing, cross-platform font rendering, or sub-pixel rendering. Playwright provides the maxDiffPixelRatio and maxDiffPixels options to tolerate small differences:
test('checkout page matches the baseline', async ({ page }) => {
await page.goto('/checkout');
await expect(page).toHaveScreenshot('checkout.png', {
maxDiffPixelRatio: 0.01,
});
});maxDiffPixelRatio: 0.01 allows up to 1 percent of pixels to differ before failing. The right value needs testing on your CI environment — too large, and it will miss real bugs.
One of the biggest visual regression challenges: baselines are created locally but run in CI — and rendering results can differ because of fonts and OS. Common solutions:
page.emulateMedia({ reducedMotion: 'reduce' }) so frames stay stable.await page.emulateMedia({ reducedMotion: 'reduce' });
await page.goto('/home');
await expect(page).toHaveScreenshot('home.png');page.emulateMedia({ reducedMotion: 'reduce' }) tells the page to use the reduced motion preference, preventing animations from changing frames between screenshots.
For animated pages, wait for the animation to finish before snapshotting — or disable animations via CSS options. Waiting for a resulting element (for example, a class that marks the animation as complete) is more reliable than guessing a duration.
Native Playwright is sufficient for most needs. External tools like Percy offer additional capabilities: intelligent comparison focused on meaningful visual changes (not raw pixels), a management dashboard, and centralized baseline approval.
Integration with a tool like Percy generally follows a similar snapshot pattern:
import percySnapshot from '@percy/playwright';
test('home page', async ({ page }) => {
await page.goto('/home');
await percySnapshot(page, 'Home');
});percySnapshot(page, 'Home') sends the snapshot to the Percy dashboard for comparison and approval. Snapshots are sent in CI mode so they don't interfere with local tests.
Consider an external tool when: a large team needs a centralized visual approval process, the application relies heavily on its appearance, or you need historical comparison across many releases. For small teams wanting a simple solution, native Playwright snapshots are enough.
npx playwright test tests/visual.spec.tsEpisode 16 made the application's appearance monitored automatically: baseline screenshots are captured and committed, toHaveScreenshot comparison detects pixel changes, thresholding tolerates rendering noise, and external tools provide centralized approval for larger teams.
Key takeaways:
toHaveScreenshot stores a baseline and compares it on subsequent runs.maxDiffPixelRatio tolerates rendering differences without hiding bugs.reducedMotion and consistent fonts keep snapshots stable across environments.In the next episode we'll discuss accessibility testing — automatic accessibility checks with axe-core integration, validating ARIA roles and labels, testing keyboard navigation, and adding accessibility assertions to the regression suite.