| name | visual-regression |
| description | Implement visual regression testing with screenshot comparison to catch unintended UI changes. Outputs Playwright/Percy test setup, baseline management, diff thresholds, and CI integration. |
| argument-hint | ["frontend framework","component library","browser targets","CI environment"] |
| allowed-tools | Read, Write, Bash |
Visual Regression Testing
Visual regression testing catches unintended UI changes — layout shifts, color changes, font differences, broken components — that functional tests miss because they don't look at pixels. A single screenshot comparison can catch what thousands of assertion lines would miss.
Process
- Choose tooling — Playwright built-in snapshots (free, self-hosted) or Percy/Chromatic (managed, better diffing).
- Identify what to capture — critical pages, all component states, responsive breakpoints.
- Establish baselines — run tests, approve initial screenshots, commit to repo.
- Configure thresholds — acceptable pixel difference percentage (0.1-1% is typical).
- Integrate with CI — run on PR, fail on unexpected changes, require approval to update.
- Manage baseline updates — deliberate UI changes must go through an update workflow.
Output Format
Playwright Visual Regression
import { defineConfig, devices } from '@playwright/test';
export default defineConfig({
testDir: './tests/visual',
snapshotDir: './tests/visual/__snapshots__',
updateSnapshots: process.env.UPDATE_SNAPSHOTS === 'true' ? 'all' : 'none',
expect: {
toHaveScreenshot: {
maxDiffPixelRatio: 0.01,
threshold: 0.2,
animations: 'disabled',
},
},
projects: [
{
name: 'chromium-desktop',
use: {
...devices['Desktop Chrome'],
viewport: { width: 1280, height: 720 },
},
},
{
name: 'mobile-safari',
use: {
...devices['iPhone 13'],
},
},
{
name: 'tablet',
use: {
...devices['iPad (gen 7)'],
},
},
],
use: {
baseURL: process.env.BASE_URL || 'http://localhost:3000',
screenshot: 'only-on-failure',
},
});
import { test, expect } from '@playwright/test';
test.describe('Homepage', () => {
test.beforeEach(async ({ page }) => {
await page.addStyleTag({
content: `
/* Hide dynamic timestamps */
[data-testid="timestamp"] { visibility: hidden; }
/* Freeze animations */
*, *::before, *::after { animation: none !important; transition: none !important; }
`
});
});
test('hero section matches baseline', async ({ page }) => {
await page.goto('/');
await page.waitForLoadState('networkidle');
await expect(page).toHaveScreenshot('homepage-hero.png', {
mask: [
page.locator('[data-testid="dynamic-banner"]'),
page.locator('.advertisement'),
],
fullPage: false,
});
});
test('full page matches baseline', async ({ page }) => {
await page.goto('/');
page.();
page.( .(, ..));
page.();
page.( .(, ));
(page).(, {
: ,
: ,
});
});
});
test.(, {
test.( ({ page }) => {
page.();
page.(, );
page.(, );
page.();
page.();
});
(, ({ page }) => {
(page).(, {
: [
page.(),
page.(),
],
});
});
(, ({ page }) => {
page.();
page.();
(page).();
});
});
import { test, expect } from '@playwright/test';
const BUTTON_STATES = ['default', 'hover', 'active', 'disabled', 'loading'];
const ALERT_VARIANTS = ['success', 'error', 'warning', 'info'];
test.describe('Button Component', () => {
for (const state of BUTTON_STATES) {
test(`button-${state}`, async ({ page }) => {
await page.goto(`/storybook?story=button--${state}`);
await page.waitForSelector('[data-storybook-ready]');
if (state === 'hover') {
await page.locator('button').hover();
}
const component = page.locator('#storybook-root');
await expect(component).toHaveScreenshot(`button-${state}.png`);
});
}
});
test.(, {
( variant ) {
(, ({ page }) => {
page.();
page.();
component = page.();
(component).();
});
}
});
test.(, {
(, ({ page }) => {
page.();
page.();
page.();
(page.()).();
});
(, ({ page }) => {
page.();
page.(, );
page.(, );
page.(, {
route.({ : , : .({ : }) });
});
page.();
page.();
(page).();
});
});
Percy Integration (Managed Service)
import { test } from '@playwright/test';
import percySnapshot from '@percy/playwright';
test('product listing page', async ({ page }) => {
await page.goto('/products');
await page.waitForLoadState('networkidle');
await percySnapshot(page, 'Product Listing', {
widths: [375, 768, 1280],
minHeight: 1024,
});
});
test('checkout flow', async ({ page }) => {
await page.goto('/cart');
await percySnapshot(page, 'Cart Page');
await page.click('[data-testid="checkout-button"]');
await page.waitForURL('/checkout');
await percySnapshot(page, 'Checkout Step 1 - Shipping');
await fillShippingForm(page);
await page.();
(page, );
});
CI Pipeline
name: Visual Regression Tests
on:
pull_request:
paths:
- 'src/**'
- 'public/**'
- 'tests/visual/**'
jobs:
visual-regression:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: '20'
cache: 'npm'
- name: Install dependencies
run: npm ci
- name: Install Playwright browsers
run: npx playwright install --with-deps chromium
- name: Build application
Snapshot Management Script
#!/bin/bash
set -e
echo "📸 Updating visual regression snapshots..."
echo ""
echo "⚠️ This will update ALL visual baselines."
echo " Only run this when UI changes are intentional."
echo ""
read -p "Are you sure? (yes/no): " confirm
if [ "$confirm" != "yes" ]; then
echo "Aborted."
exit 1
fi
UPDATE_SNAPSHOTS=true npx playwright test tests/visual/ --update-snapshots
echo ""
echo "Changed snapshots:"
git diff --stat tests/visual/__snapshots__/
echo ""
echo "Review the diffs: git diff tests/visual/__snapshots__/"
echo "Commit when satisfied: git add tests/visual/__snapshots__ && git commit -m 'test: update visual baselines'"
Rules
- Disable animations in CSS before taking screenshots — animated elements produce flaky tests.
- Mask dynamic content — dates, user names, live metrics, ads must be masked before comparison.
- Never commit failing baselines — baselines represent approved, correct UI state.
- Separate update workflow — updating baselines requires explicit intent, not an accidental flag.
- Test at multiple viewports — mobile, tablet, desktop — responsive bugs are real.
- Component-level tests are more stable — full page tests fail on any change anywhere.
- Wait for network idle — screenshot before all assets load produces flaky diffs.
- Per-browser baselines — Chrome and Firefox render fonts slightly differently; track separately.
- Review diffs as a team — visual regression failures are often legitimate UI changes, not bugs.
- Include visual tests in PR checklist — "screenshot approved" should be an explicit step.