Install any skill in seconds. Free to start, no credit card required.
Get Started Free →Visual regression testing skill using Playwright, covering screenshot comparison, visual diff thresholds, responsive testing, baseline management, and CI integration.
.claude/skills/pramoddutta-visual-regression-testing/SKILL.md| Test case | Without → With | Effect | Δ tokens | Δ turns |
|---|---|---|---|---|
| case-02 | ✗→✓ | ▲ Improved | 65% | 0% |
| case-14 | ✗→✓ | ▲ Improved | 182% | 0% |
| case-16 | ✗→✓ | ▲ Improved | 89% | 0% |
| case-23 | ✗→✓ | ▲ Improved | 185% | 0% |
| case-03 | ✓→✓ | = Same ✓ | 198% | 0% |
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.tstypescript// 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'], }, }, ], });
typescriptimport { 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, }); }); });
typescripttest.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'); }); });
typescripttest.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'); }); });
typescripttest.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, }); }); } });
typescripttest('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, }); });
typescripttest('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, }); });
typescripttest.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; } `, }); });
typescripttest('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'); });
bash# 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
markdown## 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
bash# 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)typescript// 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 }); });
typescripttest.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'); }); });
yamlvisual-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 }})' });
| Case | Status | Duration (ms) | Turns | Tokens | Tool calls | ||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| Without | With | Δ | Without | With | Δ | Without | With | Δ | Without | With | Δ | ||
case-01 | fail→fail | 25,244 | 18,852 | -25% | 1 | 1 | 0% | 4,015 | 6,400 | +59% | 0 | 0 | — |
case-02 | fail→pass | 26,812 | 21,788 | -19% | 1 | 1 | 0% | 4,362 | 7,216 | +65% | 0 | 0 | — |
case-03 | pass→pass | 20,946 | 22,988 | +10% | 1 | 1 | 0% | 2,516 | 7,507 | +198% | 0 | 0 | — |
case-04 | pass→pass | 20,994 | 16,262 | -23% | 1 | 1 | 0% | 2,244 | 6,830 | +204% | 0 | 0 | — |
case-05 | fail→fail | 22,513 | 21,984 | -2% | 1 | 1 | 0% | 2,662 | 7,068 | +166% | 0 | 0 | — |
case-06 | pass→pass | 16,796 | 18,696 | +11% | 1 | 1 | 0% | 1,874 | 6,257 | +234% | 0 | 0 | — |
case-07 | pass→pass | 15,612 | 13,474 | -14% | 1 | 1 | 0% | 1,521 | 5,073 | +234% | 0 | 0 | — |
case-08 | pass→pass | 14,244 | 13,035 | -8% | 1 | 1 | 0% | 1,427 | 5,274 | +270% | 0 | 0 | — |
case-09 | pass→pass | 9,011 | 9,241 | +3% | 1 | 1 | 0% | 1,169 | 5,375 | +360% | 0 | 0 | — |
case-10 | pass→pass | 19,655 | 11,708 | -40% | 1 | 1 | 0% | 2,387 | 6,132 | +157% | 0 | 0 | — |
case-11 | pass→pass | 17,755 | 17,850 | +1% | 1 | 1 | 0% | 2,243 | 6,366 | +184% | 0 | 0 | — |
case-12 | pass→pass | 23,144 | 23,172 | +0% | 1 | 1 | 0% | 2,865 | 6,839 | +139% | 0 | 0 | — |
case-13 | pass→pass | 20,122 | 16,624 | -17% | 1 | 1 | 0% | 2,645 | 7,051 | +167% | 0 | 0 | — |
case-14 | fail→pass | 19,945 | 22,095 | +11% | 1 | 1 | 0% | 2,450 | 6,921 | +182% | 0 | 0 | — |
case-15 | pass→pass | 22,526 | 19,097 | -15% | 1 | 1 | 0% | 2,910 | 7,274 | +150% | 0 | 0 | — |
case-16 | fail→pass | 19,826 | 20,638 | +4% | 1 | 1 | 0% | 3,625 | 6,859 | +89% | 0 | 0 | — |
case-17 | pass→pass | 20,240 | 19,366 | -4% | 1 | 1 | 0% | 2,098 | 6,172 | +194% | 0 | 0 | — |
case-18 | pass→pass | 18,685 | 20,493 | +10% | 1 | 1 | 0% | 1,920 | 6,388 | +233% | 0 | 0 | — |
case-19 | fail→fail | 22,242 | 18,752 | -16% | 1 | 1 | 0% | 2,756 | 7,014 | +154% | 0 | 0 | — |
case-20 | pass→pass | 13,335 | 9,314 | -30% | 1 | 1 | 0% | 1,271 | 5,611 | +341% | 0 | 0 | — |
case-21 | pass→pass | 20,166 | 21,502 | +7% | 1 | 1 | 0% | 2,589 | 8,007 | +209% | 0 | 0 | — |
case-22 | pass→pass | 14,585 | 17,098 | +17% | 1 | 1 | 0% | 2,666 | 7,273 | +173% | 0 | 0 | — |
case-23 | fail→pass | 18,252 | 17,174 | -6% | 1 | 1 | 0% | 2,086 | 5,940 | +185% | 0 | 0 | — |
case-24 | pass→pass | 20,308 | 20,110 | -1% | 1 | 1 | 0% | 2,552 | 6,571 | +157% | 0 | 0 | — |
DecimalAI ran this skill against gemini-3.6-flash twice over the same eval suite — once with the skill loaded and once without — and compared the two runs case by case. 24 cases were attempted. The headline lift of +17 percentage points is the difference between those two pass rates over the 24 comparable cases.
Without the skill loaded, the model failed this case. With it loaded, the same prompt on the same model passed. This is one improved case from the latest verified run; every case, including any that regressed, is in the table above.
Other measured skills in the registry, with their headline benchmark lift.