ASK KNOX
beta
LESSON 327

Visual QA in CI: Screenshot Baselines and Regression Detection

Wire Playwright screenshot comparisons into CI so visual regressions block merge automatically — with Claude analyzing diffs to give human-readable descriptions of exactly what changed.

11 min read·Unchaining UI — Visual Testing with Claude

Introduction

The previous lessons have been about the development loop: build, inspect, fix, verify. But what happens after you merge? The next developer's PR touches a shared component. A dependency update changes how Tailwind generates class names. A CSS variable gets renamed. Suddenly a dozen pages have subtle visual regressions, and nobody caught them because "it passed tests."

Visual regression CI is the answer. Capture screenshot baselines for your pages. Commit them to the repo. Run pixel-level comparisons on every PR. Regressions block merge automatically. Intentional changes get reviewed visually before merging, not discovered in production.

This lesson gives you the complete setup: Playwright toHaveScreenshot(), baseline management, a GitHub Actions workflow, the Claude analysis layer for human-readable diffs, and the cost-saving strategy for large projects.

Core Concept: Playwright toHaveScreenshot()

Playwright's toHaveScreenshot() assertion is the foundation of screenshot-based visual regression testing. It works in two modes:

First run (no baseline exists): Playwright takes a screenshot, saves it as the golden baseline file, and fails the test with Error: A snapshot doesn't exist at <path>, writing actual. This is by design — it forces you to re-run against the freshly written baseline (the second run goes green). To generate baselines without a failing run, use npx playwright test --update-snapshots (covered in section 4).

Subsequent runs: Playwright takes a new screenshot and compares it pixel-by-pixel against the saved baseline. If the difference exceeds your threshold, the test fails.

Here is the core test structure:

import { test, expect } from '@playwright/test';

test('dashboard visual regression', async ({ page }) => {
  await page.goto('/dashboard');
  await page.waitForLoadState('networkidle');
  await expect(page).toHaveScreenshot('dashboard.png', {
    maxDiffPixelRatio: 0.003,
  });
});

maxDiffPixelRatio: 0.003 means up to 0.3% of pixels can differ before the test fails. This accommodates anti-aliasing differences across environments while still catching real regressions.

The baseline file is saved at a path like:

tests/visual/dashboard.spec.ts-snapshots/dashboard-chromium-linux.png

Playwright appends the browser name and OS to the filename because rendering differs across environments. The baseline you capture on macOS will not match a Linux CI screenshot perfectly — that is why you capture baselines in CI, not on your local machine.


Setting Up the Playwright Visual Test Suite

1. Install Playwright

npm install -D @playwright/test
npx playwright install chromium

2. Configure Playwright

Create playwright.config.ts in your project root:

import { defineConfig, devices } from '@playwright/test';

export default defineConfig({
  testDir: './tests/visual',
  fullyParallel: true,
  reporter: [['html', { open: 'never' }]],
  use: {
    baseURL: 'http://localhost:3000',
    trace: 'on-first-retry',
    screenshot: 'only-on-failure',
  },
  projects: [
    {
      name: 'chromium',
      use: {
        ...devices['Desktop Chrome'],
        viewport: { width: 1440, height: 900 },
      },
    },
  ],
  webServer: {
    command: 'npm run build && npm run start',
    url: 'http://localhost:3000',
    reuseExistingServer: !process.env.CI,
    timeout: 120_000,
  },
});

The webServer block starts your app before running tests. In CI, it always does a fresh build. Locally, it reuses a running dev server if one exists.

3. Write Visual Tests

Create tests/visual/pages.spec.ts:

import { test, expect } from '@playwright/test';

const PAGES = [
  { name: 'home', path: '/' },
  { name: 'dashboard', path: '/dashboard' },
  { name: 'stats', path: '/stats' },
];

for (const { name, path } of PAGES) {
  test(`${name} page visual regression`, async ({ page }) => {
    await page.goto(path);
    await page.waitForLoadState('networkidle');

    // Hide elements that change every render: timestamps, random data, animations
    await page.addStyleTag({
      content: `
        [data-testid="timestamp"], .skeleton-loader, canvas {
          visibility: hidden !important;
        }
      `
    });

    await expect(page).toHaveScreenshot(`${name}.png`, {
      maxDiffPixelRatio: 0.003,
      animations: 'disabled',
    });
  });
}

Key techniques in this test:

  • animations: 'disabled' tells Playwright to freeze CSS animations before capturing, so animated elements do not cause false positives
  • The addStyleTag hides dynamic content (timestamps, live data, loading spinners) that will always differ between runs
  • Looping over a PAGES array keeps the test file maintainable as you add routes

4. Capture Baselines in CI

Run the tests once to generate baselines:

npx playwright test --update-snapshots

This generates the *-snapshots/ directory with baseline PNG files. Commit these to your repository.

git add tests/visual/**/*.png
git commit -m "Add visual regression baselines"

The GitHub Actions Workflow

Create .github/workflows/visual-regression.yml:

name: Visual Regression Tests

on:
  pull_request:
    branches: [main]

jobs:
  visual-regression:
    runs-on: ubuntu-latest
    timeout-minutes: 20

    steps:
      - uses: actions/checkout@v4

      - 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 app
        run: npm run build

      - name: Run visual regression tests
        run: npx playwright test
        env:
          CI: true

      - name: Upload diff artifacts on failure
        if: failure()
        uses: actions/upload-artifact@v4
        with:
          name: visual-regression-diffs
          path: test-results/
          retention-days: 7

      - name: Upload Playwright report
        if: always()
        uses: actions/upload-artifact@v4
        with:
          name: playwright-report
          path: playwright-report/
          retention-days: 7

When a test fails, GitHub Actions uploads the diff images as artifacts. You can download them from the Actions run page and see exactly which pixels changed.


The Claude Analysis Layer

Pixel diffs tell you that something changed. They do not tell you what changed or whether it matters. A 200-pixel change could be a critical layout shift or a single icon that moved 2px.

The Claude analysis layer adds a human-readable description to every failing diff.

Here is a script you can run in CI after a failing test suite:

// scripts/analyze-diffs.ts
import Anthropic from '@anthropic-ai/sdk';
import fs from 'fs';
import path from 'path';

const client = new Anthropic();

async function analyzeDiff(expectedPath: string, actualPath: string, pageName: string) {
  const expectedImage = fs.readFileSync(expectedPath).toString('base64');
  const actualImage = fs.readFileSync(actualPath).toString('base64');

  const response = await client.messages.create({
    model: 'claude-opus-4-8',
    max_tokens: 1024,
    messages: [{
      role: 'user',
      content: [
        {
          type: 'text',
          text: `These are before and after screenshots of the "${pageName}" page from a visual regression test.

Describe what visually changed between the two screenshots. Be specific:
- What elements changed position?
- What colors changed?
- What sizes changed?
- Are there elements that appeared or disappeared?
- Is this likely an intentional design change or an accidental regression?

Keep the description under 100 words.`
        },
        {
          type: 'image',
          source: { type: 'base64', media_type: 'image/png', data: expectedImage }
        },
        {
          type: 'image',
          source: { type: 'base64', media_type: 'image/png', data: actualImage }
        }
      ]
    }]
  });

  return response.content[0].type === 'text' ? response.content[0].text : '';
}

// Find all baseline/actual pairs in test-results/.
// Playwright names the files <snapshotName>-expected.png and
// <snapshotName>-actual.png inside each failing test's directory.
async function main() {
  const testResultsDir = 'test-results';
  const entries = fs.readdirSync(testResultsDir, { withFileTypes: true });

  for (const entry of entries) {
    if (!entry.isDirectory()) continue;

    const dir = entry.name;
    const files = fs.readdirSync(path.join(testResultsDir, dir));
    const expectedFile = files.find((f) => f.endsWith('-expected.png'));
    const actualFile = files.find((f) => f.endsWith('-actual.png'));

    if (expectedFile && actualFile) {
      const expectedPath = path.join(testResultsDir, dir, expectedFile);
      const actualPath = path.join(testResultsDir, dir, actualFile);
      const description = await analyzeDiff(expectedPath, actualPath, dir);
      console.log(`\n## ${dir}\n${description}`);
    }
  }
}

main().catch((err) => {
  console.error(err);
  process.exit(1);
});

Add this as a CI step after the Playwright run:

- name: Analyze visual diffs with Claude
  if: failure()
  run: npx ts-node scripts/analyze-diffs.ts
  env:
    ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }}

The PR comment or CI log now reads like:

## dashboard
The header logo shifted approximately 2 pixels to the left. The primary
"Export" button's border-radius changed from 8px to 4px, making corners
sharper. All other elements appear unchanged. This looks like a regression
from a CSS variable change rather than an intentional update.

This is far more useful than "37 pixels differed."


Updating Baselines for Intentional Changes

When you intentionally update a component's design, the visual regression test will fail — correctly. The old baseline no longer matches the new design. You need to update the baseline.

The workflow:

  1. Make your design change
  2. Run tests locally: npx playwright test → tests fail as expected
  3. Update the snapshots: npx playwright test --update-snapshots
  4. Review the updated baseline images in your Git diff: git diff tests/visual/**/*.png
  5. If the changes look correct, commit the updated baselines as part of your PR

Your PR now contains both the CSS change and the updated visual baseline. Reviewers can see the before/after visually in the Git diff (GitHub renders PNG diffs inline).

Step back and the baseline file has a whole life of its own — born failing to force itself into the repo, green on every match after, and re-minted only when you deliberately update it. Seeing those states as one arc clarifies why the first run "fails" and why --update-snapshots is the only sanctioned way to change a golden.

# Update all snapshots
npx playwright test --update-snapshots

# Update snapshots for a specific test file only
npx playwright test tests/visual/dashboard.spec.ts --update-snapshots

# Preview what changed before committing
git diff --stat tests/visual/

Cost-Effective Strategy: Test Only Changed Pages

A large project may have 50+ pages. Running visual regression on all 50 for every PR is wasteful — most PRs touch only 1–3 pages. The cost-saving strategy is to map code changes to affected routes and only test those pages.

# scripts/affected-routes.sh
# Outputs a JSON array of routes affected by files changed in this PR

CHANGED_FILES=$(git diff --name-only origin/main...HEAD)

# Map file paths to routes using your project's conventions
# This is project-specific — adjust the patterns to match your routing
python3 - <<EOF
import json, sys

changed = """$CHANGED_FILES""".strip().split("\n")

routes = set()
for f in changed:
    if "components/Dashboard" in f or "pages/dashboard" in f:
        routes.add("/dashboard")
    if "components/StatsCard" in f:
        # StatsCard appears on multiple pages
        routes.update(["/dashboard", "/overview", "/reports"])
    if "app/page.tsx" in f or "pages/index" in f:
        routes.add("/")
    # Global changes: test everything
    if "components/Layout" in f or "styles/globals" in f:
        routes.update(["/", "/dashboard", "/stats", "/reports"])

print(json.dumps(list(routes)))
EOF

In your Playwright config, read the affected routes from an environment variable set by this script, and only test those pages:

const affectedRoutes = process.env.AFFECTED_ROUTES
  ? JSON.parse(process.env.AFFECTED_ROUTES)
  : null; // null = test everything

const ALL_PAGES = [
  { name: 'home', path: '/' },
  { name: 'dashboard', path: '/dashboard' },
  { name: 'stats', path: '/stats' },
  { name: 'reports', path: '/reports' },
];

const PAGES_TO_TEST = affectedRoutes
  ? ALL_PAGES.filter(p => affectedRoutes.includes(p.path))
  : ALL_PAGES;

For a PR that touches one component, this reduces test time from 8 minutes to 90 seconds.


Complete Example: Full Visual Regression Setup

Here is the complete picture of what a well-configured visual regression suite looks like in a real project:

File structure:

tests/
  visual/
    pages.spec.ts          — page-level visual tests
    components.spec.ts     — isolated component tests in Storybook
playwright.config.ts
.github/
  workflows/
    visual-regression.yml
scripts/
  analyze-diffs.ts
  update-baselines.sh

components.spec.ts — testing components in Storybook:

import { test, expect } from '@playwright/test';

const COMPONENTS = [
  { name: 'stats-card-default', path: '/storybook/iframe.html?id=statscard--default' },
  { name: 'stats-card-negative', path: '/storybook/iframe.html?id=statscard--negative-trend' },
  { name: 'nav-desktop', path: '/storybook/iframe.html?id=nav--desktop' },
];

for (const { name, path } of COMPONENTS) {
  test(`${name} component regression`, async ({ page }) => {
    await page.goto(path);
    await page.waitForLoadState('networkidle');
    await expect(page).toHaveScreenshot(`${name}.png`, {
      maxDiffPixelRatio: 0.003,
      animations: 'disabled',
    });
  });
}

Testing components in Storybook isolation is more reliable than testing full pages because there is no dynamic data, no authentication, and no network dependencies.


Common Mistakes

Capturing baselines on macOS and running comparisons on Linux CI. Font rendering differs enough to cause false positives. Always capture baselines in the same environment where tests run — usually Linux in CI. Use --update-snapshots in CI once, commit the output.

Not hiding dynamic content before capture. Timestamps, "last updated" labels, skeleton loaders, and real-time data will always differ. Use addStyleTag to hide them before the snapshot.

Setting maxDiffPixelRatio: 0 (strict pixel-perfect). This will fail on any CI where anti-aliasing differs slightly from baseline. Use 0.001–0.003 as your floor.

Committing 50MB of baseline PNGs. Baseline screenshots are binary blobs. For large projects, use Git LFS or a dedicated artifact storage service (Argos CI, Percy, Chromatic) rather than committing them directly to the repo.

Running visual regression on every push to every branch. Run it only on PRs targeting main. It is expensive and creates noise on feature branches that are actively changing.


Summary

Visual regression CI is the production-grade endpoint of the visual testing track. You capture baseline screenshots in CI, compare against them on every PR, and block merge when regressions appear. The Claude analysis layer converts pixel diffs into human-readable descriptions of exactly what changed and whether it looks intentional.

The stack: Playwright toHaveScreenshot() for pixel comparison, GitHub Actions for CI execution, artifact upload for diff inspection, Claude for diff analysis, and --update-snapshots for baseline updates on intentional changes. Cost-optimize by mapping code changes to affected routes and only testing those.


What's Next

You have now completed the full visual testing track: manual inspection, structured auditing, the Build-See-Fix-Verify loop, responsive QA, Computer-Use MCP automation, and CI regression detection. You can see what Claude Code builds. The next track turns to the engineering discipline that keeps what you ship reliable — confronting the Vibe Coder's Wall, weighing failure frequency against magnitude, and running the Six Checkpoints that separate shipping from generating.


Exercise

Set up Playwright visual regression for a project you are actively developing.

  1. Install Playwright and create the config from this lesson
  2. Write visual tests for 3 pages using the toHaveScreenshot() pattern
  3. Run --update-snapshots to capture baselines. Commit them.
  4. Intentionally break CSS on one page (change a background color or font size)
  5. Run the tests. Confirm the failing test catches your change.
  6. Wire the GitHub Actions workflow into your repo. Open a PR with the CSS break and watch the visual regression job fail.
  7. Fix the CSS. Watch the job pass.
  8. Intentionally update the design (change a button color). Update the baselines with --update-snapshots. Commit the new baselines as part of the PR.

Document: time to set up, time per CI run, false positive rate on first run (before you tuned maxDiffPixelRatio), and any baselines that were harder to stabilize than expected.