|$ 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

TestingIntermediate🎯Free Tools
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
1import { test, expect } from "@playwright/test";
2
3test("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
13test("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
32test("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
2npx playwright test --update-snapshots
3
4# Run with specific threshold
5npx 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
2import percySnapshot from "@percy/playwright";
3
4test("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
15test("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
3import type { Meta, StoryObj } from "@storybook/react";
4import { Button } from "./Button";
5
6const 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};
15export default meta;
16
17type Story = StoryObj<typeof Button>;
18
19export const Primary: Story = {};
20export const Secondary: Story = { args: { variant: "secondary" } };
21export const Danger: Story = { args: { variant: "danger" } };
22export const Disabled: Story = { args: { disabled: true } };
23export 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
2name: Visual Regression Tests
3on: [pull_request]
4
5jobs:
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