|$ curl https://forge-ai.dev/api/markdown?path=docs/testing/visual-regression
$cat docs/visual-regression-testing.md
updated Recently·28 min read·published
Visual Regression Testing
Introduction
Visual regression testing captures screenshots of your UI and compares them against baselines to detect unintended visual changes. While unit tests verify logic, visual tests verify appearance — catching CSS bugs, layout shifts, font changes, and rendering issues.
Playwright Screenshots
visual.spec.ts
TypeScript
| 1 | import { test, expect } from "@playwright/test"; |
| 2 | |
| 3 | test("homepage matches snapshot", async ({ page }) => { |
| 4 | await page.goto("http://localhost:3000"); |
| 5 | await page.waitForLoadState("networkidle"); |
| 6 | |
| 7 | // Full page screenshot |
| 8 | await expect(page).toHaveScreenshot("homepage.png", { |
| 9 | maxDiffPixelRatio: 0.01, // Allow 1% difference |
| 10 | }); |
| 11 | }); |
| 12 | |
| 13 | test("button states match snapshots", async ({ page }) => { |
| 14 | await page.goto("http://localhost:3000/buttons"); |
| 15 | |
| 16 | // Screenshot specific element |
| 17 | const button = page.locator("#primary-button"); |
| 18 | await expect(button).toHaveScreenshot("primary-button.png"); |
| 19 | |
| 20 | // Multiple states |
| 21 | await button.hover(); |
| 22 | await expect(button).toHaveScreenshot("primary-button-hover.png"); |
| 23 | |
| 24 | await button.click(); |
| 25 | await expect(button).toHaveScreenshot("primary-button-active.png"); |
| 26 | |
| 27 | await button.setAttribute("disabled", ""); |
| 28 | await expect(button).toHaveScreenshot("primary-button-disabled.png"); |
| 29 | }); |
| 30 | |
| 31 | // Responsive testing |
| 32 | test("homepage at different viewports", async ({ page }) => { |
| 33 | const viewports = [ |
| 34 | { width: 375, height: 667, name: "mobile" }, |
| 35 | { width: 768, height: 1024, name: "tablet" }, |
| 36 | { width: 1440, height: 900, name: "desktop" }, |
| 37 | ]; |
| 38 | |
| 39 | for (const vp of viewports) { |
| 40 | await page.setViewportSize({ width: vp.width, height: vp.height }); |
| 41 | await page.goto("http://localhost:3000"); |
| 42 | await expect(page).toHaveScreenshot(`homepage-${vp.name}.png`); |
| 43 | } |
| 44 | }); |
update-snapshots.sh
Bash
| 1 | # Update baselines when visual changes are intentional |
| 2 | npx playwright test --update-snapshots |
| 3 | |
| 4 | # Run with specific threshold |
| 5 | npx playwright test --grep "visual" --update-snapshots |
| 6 | |
| 7 | # Configuration in playwright.config.ts: |
| 8 | # expect: { |
| 9 | # toHaveScreenshot: { |
| 10 | # maxDiffPixelRatio: 0.01, |
| 11 | # threshold: 0.2, |
| 12 | # animations: "disabled", |
| 13 | # }, |
| 14 | # } |
ℹ
info
Set animations: "disabled" in Playwright config to freeze CSS animations and transitions during screenshots, preventing flaky comparisons.
Percy by BrowserStack
percy.spec.ts
TypeScript
| 1 | // Percy integration with Playwright |
| 2 | import percySnapshot from "@percy/playwright"; |
| 3 | |
| 4 | test("homepage percy snapshot", async ({ page }) => { |
| 5 | await page.goto("http://localhost:3000"); |
| 6 | await page.waitForLoadState("networkidle"); |
| 7 | |
| 8 | // Full page snapshot sent to Percy dashboard |
| 9 | await percySnapshot(page, "Homepage", { |
| 10 | widths: [375, 768, 1440], // Test multiple viewports |
| 11 | }); |
| 12 | }); |
| 13 | |
| 14 | // Percy with responsive breakpoints |
| 15 | test("dashboard percy snapshot", async ({ page }) => { |
| 16 | await page.goto("http://localhost:3000/dashboard"); |
| 17 | await percySnapshot(page, "Dashboard", { |
| 18 | widths: [375, 768, 1024, 1440], |
| 19 | minHeight: 1024, |
| 20 | }); |
| 21 | }); |
Chromatic (Storybook)
Button.stories.tsx
TypeScript
| 1 | // Chromatic visual tests from Storybook stories |
| 2 | // Button.stories.ts |
| 3 | import type { Meta, StoryObj } from "@storybook/react"; |
| 4 | import { Button } from "./Button"; |
| 5 | |
| 6 | const meta: Meta<typeof Button> = { |
| 7 | title: "Components/Button", |
| 8 | component: Button, |
| 9 | args: { |
| 10 | children: "Click me", |
| 11 | variant: "primary", |
| 12 | size: "md", |
| 13 | }, |
| 14 | }; |
| 15 | export default meta; |
| 16 | |
| 17 | type Story = StoryObj<typeof Button>; |
| 18 | |
| 19 | export const Primary: Story = {}; |
| 20 | export const Secondary: Story = { args: { variant: "secondary" } }; |
| 21 | export const Danger: Story = { args: { variant: "danger" } }; |
| 22 | export const Disabled: Story = { args: { disabled: true } }; |
| 23 | export const Loading: Story = { args: { loading: true } }; |
| 24 | |
| 25 | // npx chromatic --project-token=xxx |
| 26 | // Chromatic compares every story against its baseline |
| 27 | // and provides a visual diff review UI |
✓
best practice
Chromatic works best with component libraries built on Storybook. Every story becomes a visual test case, and the review UI makes it easy to accept or reject visual changes.
CI Integration
visual-ci.yml
YAML
| 1 | # GitHub Actions visual regression workflow |
| 2 | name: Visual Regression Tests |
| 3 | on: [pull_request] |
| 4 | |
| 5 | jobs: |
| 6 | visual: |
| 7 | runs-on: ubuntu-latest |
| 8 | steps: |
| 9 | - uses: actions/checkout@v4 |
| 10 | with: |
| 11 | fetch-depth: 0 # Full history for baseline comparison |
| 12 | |
| 13 | - uses: actions/setup-node@v4 |
| 14 | with: |
| 15 | node-version: 20 |
| 16 | |
| 17 | - run: npm ci |
| 18 | - run: npx playwright install --with-deps |
| 19 | |
| 20 | - name: Run visual tests |
| 21 | run: npx playwright test --grep "visual" |
| 22 | |
| 23 | - name: Upload visual diffs |
| 24 | if: failure() |
| 25 | uses: actions/upload-artifact@v4 |
| 26 | with: |
| 27 | name: visual-diffs |
| 28 | path: test-results/ |
| 29 | |
| 30 | # Update baselines on main branch |
| 31 | - name: Update baselines |
| 32 | if: github.ref == 'refs/heads/main' |
| 33 | run: npx playwright test --update-snapshots |
| 34 | # Then commit updated baselines |
$Blueprint — Engineering Documentation·Section ID: TEST-VR-01·Revision: 1.0