Visual Regression Testing With Playwright: Screenshots & Comparisons
Visual regression testing detects unintended changes to your React app's appearance—a CSS update that breaks the button on mobile, a font size change that makes text overflow, or a color change that creates an accessibility contrast issue. Playwright captures screenshots and compares them to baselines, failing tests when pixels differ beyond a threshold. This article covers baseline creation, comparison strategies, handling expected changes, and integrating visual tests into your CI pipeline.
What Is Visual Regression Testing and Why It Matters
Visual regression testing captures a screenshot of a component or page and compares it to a baseline image. If the new screenshot differs from the baseline, the test fails—alerting you to unexpected visual changes. This catches bugs that functional tests miss: layout breaks, color shifts, typography issues, and cross-browser inconsistencies.
Example: you refactor CSS for a button, intending only to adjust padding. But you accidentally change the background color. A functional test won't catch this (the button still clicks), but a visual test catches the color change immediately.
import { test, expect } from '@playwright/test';
test('button appearance is consistent', async ({ page }) => {
await page.goto('/components/button');
const button = page.locator('[data-testid="primary-button"]');
// Capture a screenshot and compare to baseline
// First run: creates baseline-1.png
// Subsequent runs: compares to baseline-1.png, fails if different
await expect(button).toHaveScreenshot('button-primary.png');
});
Setting Up Visual Regression Testing Baselines
The first time you run a visual test, Playwright creates a baseline screenshot. On subsequent runs, it compares the new screenshot to the baseline. Create baselines intentionally:
import { test, expect } from '@playwright/test';
test('button variations', async ({ page }) => {
await page.goto('/components/button');
// Primary button
const primaryBtn = page.locator('[data-testid="btn-primary"]');
await expect(primaryBtn).toHaveScreenshot('btn-primary.png');
// Secondary button
const secondaryBtn = page.locator('[data-testid="btn-secondary"]');
await expect(secondaryBtn).toHaveScreenshot('btn-secondary.png');
// Disabled button
const disabledBtn = page.locator('[data-testid="btn-disabled"]');
await expect(disabledBtn).toHaveScreenshot('btn-disabled.png');
});
When you first run this test, Playwright creates three baseline images:
tests/__screenshots__/
button.spec.ts-chromium/
btn-primary.png
btn-secondary.png
btn-disabled.png
button.spec.ts-firefox/
btn-primary.png
... (baselines for each browser)
button.spec.ts-webkit/
... (WebKit baselines)
Commit these baselines to your repository—they're part of your test data.
Comparing Baselines and Handling Legitimate Changes
When you update your design intentionally, Playwright's visual tests fail because the screenshots differ from baselines. Update the baselines:
# Run tests and capture new baselines (overwriting old ones)
npx playwright test --update-snapshots
After updating, review the changes (ideally via git diff --binary or your screenshot tool) before committing the new baselines.
// Before: button has padding 8px
// After: button has padding 16px
test('button has proper spacing', async ({ page }) => {
await page.goto('/components/button');
const button = page.locator('[data-testid="button"]');
await expect(button).toHaveScreenshot('button.png');
// First run: fails (padding changed)
// Run with --update-snapshots: creates new baseline
// Subsequent runs: passes (new baseline matches)
});
Comparing Full Pages vs. Individual Components
Screenshot both full pages and components, depending on your testing needs:
test('full page screenshot', async ({ page }) => {
await page.goto('/dashboard');
// Capture entire page (including scrollable content)
await expect(page).toHaveScreenshot('dashboard-full.png', {
fullPage: true,
});
});
test('component screenshot', async ({ page }) => {
await page.goto('/components/card');
// Capture just the card component
const card = page.locator('[data-testid="card"]');
await expect(card).toHaveScreenshot('card.png');
});
test('modal dialog screenshot', async ({ page }) => {
await page.goto('/');
// Trigger the modal
await page.locator('[data-testid="open-modal"]').click();
// Wait for modal to be visible
const modal = page.locator('[data-testid="modal"]');
await expect(modal).toBeVisible();
// Capture the modal
await expect(modal).toHaveScreenshot('modal.png');
});
Component screenshots are faster and more granular (easier to spot what changed). Full-page screenshots verify overall layout, but they're slower and more brittle (any change on the page fails the test).
Handling Dynamic Content and Masking Elements
Screenshots of components with dynamic content (timestamps, user IDs) will always differ. Mask these elements:
test('user profile card', async ({ page }) => {
await page.goto('/profile');
const card = page.locator('[data-testid="profile-card"]');
// Mask the timestamp (dynamic content)
await expect(card).toHaveScreenshot('profile-card.png', {
mask: [page.locator('[data-testid="timestamp"]')],
});
});
test('product list with prices', async ({ page }) => {
await page.goto('/products');
// Mask elements that change frequently
await expect(page).toHaveScreenshot('products.png', {
mask: [
page.locator('[data-testid="price"]'), // prices change
page.locator('[data-testid="discount"]'), // discounts vary
],
});
});
Masked elements are blurred in the screenshot, so differences in those areas don't fail the test.
Cross-Browser Visual Testing
Playwright tests on Chromium, Firefox, and WebKit. Visual baselines are per-browser, so you catch browser-specific rendering issues:
import { test, expect, devices } from '@playwright/test';
test.describe('cross-browser visual tests', () => {
// Create separate tests for each browser's visual characteristics
test('button appears correctly on chromium', async ({ page }) => {
await page.goto('/components/button');
const button = page.locator('[data-testid="button"]');
await expect(button).toHaveScreenshot('button-chromium.png');
});
test('button appears correctly on firefox', async ({ page }) => {
await page.goto('/components/button');
const button = page.locator('[data-testid="button"]');
await expect(button).toHaveScreenshot('button-firefox.png');
});
test('button appears correctly on webkit', async ({ page }) => {
await page.goto('/components/button');
const button = page.locator('[data-testid="button"]');
await expect(button).toHaveScreenshot('button-webkit.png');
});
});
// Or use Playwright's built-in browser projects
// (default config captures baselines for all browsers)
Playwright automatically captures baselines for each browser project, so you catch cross-browser visual regressions.
Responsive Design Testing with Visual Regression
Test how components look at different viewport sizes:
import { test, expect } from '@playwright/test';
test.describe('responsive design', () => {
test('card layout on desktop', async ({ page }) => {
await page.setViewportSize({ width: 1280, height: 720 });
await page.goto('/components/card');
const card = page.locator('[data-testid="card"]');
await expect(card).toHaveScreenshot('card-desktop.png');
});
test('card layout on tablet', async ({ page }) => {
await page.setViewportSize({ width: 768, height: 1024 });
await page.goto('/components/card');
const card = page.locator('[data-testid="card"]');
await expect(card).toHaveScreenshot('card-tablet.png');
});
test('card layout on mobile', async ({ page }) => {
await page.setViewportSize({ width: 375, height: 667 });
await page.goto('/components/card');
const card = page.locator('[data-testid="card"]');
await expect(card).toHaveScreenshot('card-mobile.png');
});
});
Responsive visual tests ensure your React components adapt correctly to different screen sizes.
Configuring Comparison Thresholds
By default, Playwright allows a small pixel difference (0.2% threshold). Increase or decrease this tolerance as needed:
test('button screenshot with strict comparison', async ({ page }) => {
await page.goto('/components/button');
const button = page.locator('[data-testid="button"]');
// Strict: fail if even 1 pixel differs
await expect(button).toHaveScreenshot('button.png', {
maxDiffPixels: 0,
});
});
test('page screenshot with lenient comparison', async ({ page }) => {
await page.goto('/dashboard');
// Lenient: allow up to 100 pixels to differ
await expect(page).toHaveScreenshot('dashboard.png', {
maxDiffPixels: 100,
});
});
test('screenshot with percentage threshold', async ({ page }) => {
await page.goto('/');
// Allow 1% of pixels to differ
await expect(page).toHaveScreenshot('page.png', {
threshold: 0.01,
});
});
Stricter thresholds catch small visual changes; lenient thresholds reduce false positives from minor browser rendering variations.
Debugging Failed Visual Tests
When a visual test fails, Playwright shows a diff image in the HTML report. Open the report in a browser:
npx playwright show-report
The report displays side-by-side comparisons of baseline, actual, and diff images, making it easy to spot what changed. If the change is intentional, update the baseline; if not, investigate the code change that caused the visual regression.
Integrating Visual Tests into CI
In CI, visual tests run on a consistent environment (same OS, browser, fonts), so baselines are stable:
# GitHub Actions example
name: Visual Regression Tests
on: [push, pull_request]
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
- run: npm install
- run: npx playwright install
- run: npm run test:visual
- uses: actions/upload-artifact@v3
if: always()
with:
name: playwright-report
path: playwright-report/
Store visual baselines in your repo or a CI artifact store so they're consistent across CI runs and developer machines.
Key Takeaways
- Visual regression testing captures screenshots and compares them to baselines, catching unintended design changes.
- Create baselines on the first run; subsequent runs compare to baselines and fail if pixels differ.
- Update baselines intentionally with
--update-snapshotswhen design changes are approved. - Mask dynamic content (timestamps, IDs) so they don't cause false negatives.
- Test across browsers and viewports to catch responsive design bugs.
- Store baselines in your repository and review diffs before committing changes.
Frequently Asked Questions
How do I share visual baselines across my team?
Commit baselines to your repository (in tests/__screenshots__/). All developers and CI use the same baselines, ensuring consistency. Update baselines in a PR; teammates review the visual diffs before merging.
What if screenshots look slightly different on different machines?
Use CI to capture baselines on a consistent environment (same OS, browser version, fonts). Developers run tests against those CI-captured baselines. If you must run visual tests locally, configure playwright.config.ts to use a Docker container for consistent rendering.
Can I use visual tests for accessibility?
Visual tests catch color contrast issues and layout problems, but they don't fully test accessibility. Combine visual tests with accessibility auditing tools (axe, Lighthouse) for comprehensive coverage.
How do I test animations with visual regression?
Screenshots capture a single frame. To test animations, capture screenshots at different points in the animation or use a tool like Playwright's video recording. Alternatively, disable animations for testing: await page.addInitScript(() => { document.documentElement.style.animationDuration = '0s'; });
Should I commit visual baselines to Git or store them separately?
Commit baselines to Git for version control and easy reviewing. This makes PRs self-contained (baselines are part of the diff). If baselines are large (>10 MB total), use Git LFS or a separate artifact store.