by thetestingacademy
Visual regression testing with Playwright screenshots and diff comparison
npx @qaskills/cli add visual-regressionAuto-detects your AI agent and installs the skill. Works with Claude Code, Cursor, Copilot, and more.
You are an expert QA engineer specializing in visual regression testing with Playwright. When the user asks you to write, review, or debug visual regression tests, follow these detailed instructions.
tests/
visual/
pages/
homepage.visual.spec.ts
login.visual.spec.ts
dashboard.visual.spec.ts
components/
navigation.visual.spec.ts
footer.visual.spec.ts
card.visual.spec.ts
responsive/
homepage.responsive.spec.ts
checkout.responsive.spec.ts
utils/
visual-helpers.ts
mask-helpers.ts
visual.config.ts
snapshots/ <-- baseline screenshots (committed to git)
homepage-chromium.png
login-chromium.png
playwright.config.ts
// playwright.config.ts
import { defineConfig, devices } from '@playwright/test';
export default defineConfig({
testDir: './tests/visual',
snapshotDir: './tests/snapshots',
snapshotPathTemplate: '{snapshotDir}/{testFileDir}/{testFileName}-snapshots/{arg}{-projectName}{ext}',
fullyParallel: true,
retries: 0, // Visual tests should not retry -- flaky visuals indicate real issues
use: {
baseURL: 'http://localhost:3000',
screenshot: 'only-on-failure',
trace: 'retain-on-failure',
},
expect: {
toHaveScreenshot: {
maxDiffPixels: 100, // Allow up to 100 pixels difference
maxDiffPixelRatio: 0.01, // Or 1% of total pixels
threshold: 0.2, // Per-pixel color threshold (0-1)
animations: 'disabled', // Disable CSS animations
},
toMatchSnapshot: {
maxDiffPixelRatio: 0.01,
},
},
projects: [
{
name: 'chromium',
use: {
...devices['Desktop Chrome'],
// Force consistent font rendering
launchOptions: {
args: ['--font-render-hinting=none', '--disable-skia-runtime-opts'],
},
},
},
{
name: 'firefox',
use: { ...devices['Desktop Firefox'] },
},
{
name: 'webkit',
use: { ...devices['Desktop Safari'] },
},
{
name: 'mobile-portrait',
use: {
...devices['iPhone 13'],
},
},
{
name: 'tablet',
use: {
...devices['iPad Pro 11'],
},
},
],
});
import { test, expect } from '@playwright/test';
test.describe('Homepage Visual Tests', () => {
test.beforeEach(async ({ page }) => {
await page.goto('/');
await page.waitForLoadState('networkidle');
});
test('homepage should match baseline', async ({ page }) => {
await expect(page).toHaveScreenshot('homepage-full.png', {
fullPage: true,
animations: 'disabled',
});
});
test('homepage above-the-fold should match baseline', async ({ page }) => {
await expect(page).toHaveScreenshot('homepage-above-fold.png', {
fullPage: false, // Viewport only
});
});
test('homepage with content loaded should match baseline', async ({ page }) => {
// Wait for all dynamic content
await page.getByRole('heading', { name: 'Featured Products' }).waitFor();
await page.waitForSelector('img[src*="product"]', { state: 'visible' });
await expect(page).toHaveScreenshot('homepage-loaded.png', {
fullPage: true,
});
});
});
test.describe('Navigation Visual Tests', () => {
test('desktop navigation should match baseline', async ({ page }) => {
await page.goto('/');
const nav = page.getByRole('navigation', { name: 'Main' });
await expect(nav).toHaveScreenshot('nav-desktop.png');
});
test('navigation hover state should match baseline', async ({ page }) => {
await page.goto('/');
const productsLink = page.getByRole('link', { name: 'Products' });
await productsLink.hover();
await expect(page.getByRole('navigation')).toHaveScreenshot('nav-hover.png');
});
test('navigation dropdown should match baseline', async ({ page }) => {
await page.goto('/');
await page.getByRole('button', { name: 'Account' }).click();
const dropdown = page.getByRole('menu');
await expect(dropdown).toHaveScreenshot('nav-dropdown.png');
});
});
test.describe('Form Visual States', () => {
test('empty form should match baseline', async ({ page }) => {
await page.goto('/register');
await expect(page.locator('form')).toHaveScreenshot('form-empty.png');
});
test('form with validation errors should match baseline', async ({ page }) => {
await page.goto('/register');
await page.getByRole('button', { name: 'Submit' }).click();
// Wait for validation messages to appear
await page.getByText('Email is required').waitFor();
await expect(page.locator('form')).toHaveScreenshot('form-errors.png');
});
test('form with filled data should match baseline', async ({ page }) => {
await page.goto('/register');
await page.getByLabel('Name').fill('John Doe');
await page.getByLabel('Email').fill('john@example.com');
await page.getByLabel('Password').fill('SecurePass123!');
await expect(page.locator('form')).toHaveScreenshot('form-filled.png');
});
test('disabled button state should match baseline', async ({ page }) => {
await page.goto('/register');
const button = page.getByRole('button', { name: 'Submit' });
await expect(button).toHaveScreenshot('button-disabled.png');
});
});
test.describe('Responsive Layout Tests', () => {
const viewports = [
{ name: 'mobile', width: 375, height: 667 },
{ name: 'tablet', width: 768, height: 1024 },
{ name: 'desktop', width: 1280, height: 720 },
{ name: 'wide', width: 1920, height: 1080 },
];
for (const viewport of viewports) {
test(`homepage at ${viewport.name} (${viewport.width}x${viewport.height})`, async ({ page }) => {
await page.setViewportSize({ width: viewport.width, height: viewport.height });
await page.goto('/');
await page.waitForLoadState('networkidle');
await expect(page).toHaveScreenshot(`homepage-${viewport.name}.png`, {
fullPage: true,
});
});
}
});
test('dashboard should match baseline with dynamic content masked', async ({ page }) => {
await page.goto('/dashboard');
await expect(page).toHaveScreenshot('dashboard.png', {
mask: [
page.locator('[data-testid="current-time"]'),
page.locator('[data-testid="user-avatar"]'),
page.locator('[data-testid="notification-count"]'),
page.locator('.chart-container'), // Dynamic chart data
page.locator('.ad-banner'), // Third-party ads
],
fullPage: true,
});
});
test('profile page should match baseline', async ({ page }) => {
await page.goto('/profile');
// Replace dynamic text with consistent values
await page.evaluate(() => {
// Replace timestamps
document.querySelectorAll('[data-testid="timestamp"]').forEach((el) => {
el.textContent = 'January 1, 2024';
});
// Replace user-specific data
const nameEl = document.querySelector('[data-testid="user-name"]');
if (nameEl) nameEl.textContent = 'Test User';
// Remove random elements
document.querySelectorAll('.random-recommendation').forEach((el) => el.remove());
});
await expect(page).toHaveScreenshot('profile-page.png', {
fullPage: true,
});
});
test.beforeEach(async ({ page }) => {
// Disable all CSS animations and transitions
await page.addStyleTag({
content: `
*, *::before, *::after {
animation-duration: 0s !important;
animation-delay: 0s !important;
transition-duration: 0s !important;
transition-delay: 0s !important;
scroll-behavior: auto !important;
}
`,
});
});
test('page with custom fonts should match baseline', async ({ page }) => {
await page.goto('/');
// Wait for fonts to load
await page.evaluate(() => document.fonts.ready);
// Additional wait for font rendering
await page.waitForTimeout(500); // acceptable for font rendering
await expect(page).toHaveScreenshot('page-with-fonts.png');
});
# Update all baselines
npx playwright test --update-snapshots
# Update baselines for specific tests
npx playwright test tests/visual/homepage.visual.spec.ts --update-snapshots
# Update baselines for specific project
npx playwright test --project=chromium --update-snapshots
## Baseline Update Process
1. **Intentional change:** Developer modifies UI deliberately
2. **Visual tests fail:** CI detects the visual difference
3. **Review the diff:** Download artifacts, inspect the visual diff
4. **Approve the change:** If the change is intended:
a. Run `npx playwright test --update-snapshots` locally
b. Commit the updated baseline screenshots
c. Push and verify CI passes
5. **Reject the change:** If the change is unintended:
a. Revert the code change causing the visual difference
b. Verify visual tests pass again
# Install Git LFS
git lfs install
# Track screenshot files
git lfs track "tests/snapshots/**/*.png"
git lfs track "tests/snapshots/**/*.jpg"
# Add .gitattributes
git add .gitattributes
git commit -m "Track visual baselines with Git LFS"
When a visual test fails, Playwright generates three images:
test-results/
homepage-visual-spec-ts/
homepage-full-chromium-expected.png <-- Baseline (what it should look like)
homepage-full-chromium-actual.png <-- Current (what it looks like now)
homepage-full-chromium-diff.png <-- Diff (highlighted differences)
// Strict comparison for brand-critical pages
test('brand logo should be pixel-perfect', async ({ page }) => {
await page.goto('/');
const logo = page.locator('[data-testid="brand-logo"]');
await expect(logo).toHaveScreenshot('brand-logo.png', {
maxDiffPixels: 0, // Zero tolerance
threshold: 0, // Exact pixel match
});
});
// Relaxed comparison for content-heavy pages
test('blog listing visual check', async ({ page }) => {
await page.goto('/blog');
await expect(page).toHaveScreenshot('blog-listing.png', {
maxDiffPixelRatio: 0.05, // Allow 5% difference
threshold: 0.3, // More color tolerance
});
});
test.describe('Dark Mode Visual Tests', () => {
test('homepage in dark mode', async ({ page }) => {
await page.emulateMedia({ colorScheme: 'dark' });
await page.goto('/');
await expect(page).toHaveScreenshot('homepage-dark.png', { fullPage: true });
});
test('homepage in light mode', async ({ page }) => {
await page.emulateMedia({ colorScheme: 'light' });
await page.goto('/');
await expect(page).toHaveScreenshot('homepage-light.png', { fullPage: true });
});
test('reduced motion preference', async ({ page }) => {
await page.emulateMedia({ reducedMotion: 'reduce' });
await page.goto('/');
// Verify no animations are visible
await expect(page).toHaveScreenshot('homepage-reduced-motion.png');
});
});
visual-tests:
name: Visual Regression Tests
runs-on: ubuntu-latest
timeout-minutes: 30
container:
image: mcr.microsoft.com/playwright:v1.42.0-jammy
steps:
- uses: actions/checkout@v4
with:
lfs: true # Important: fetch LFS baselines
- uses: actions/setup-node@v4
with:
node-version: '20'
cache: 'npm'
- run: npm ci
- name: Run Visual Tests
run: npx playwright test tests/visual/
- name: Upload Visual Diff
if: failure()
uses: actions/upload-artifact@v4
with:
name: visual-diffs
path: |
test-results/**/
retention-days: 14
- name: Comment PR with Visual Diff
if: failure() && github.event_name == 'pull_request'
uses: actions/github-script@v7
with:
script: |
github.rest.issues.createComment({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: context.issue.number,
body: '## Visual Regression Detected\n\nVisual differences were found. Please download the artifacts to review the diffs.\n\n[View workflow run](${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }})'
});
- name: Install QA Skills
run: npx @qaskills/cli add visual-regression10 of 29 agents supported