Expert in visual regression testing with Playwright, Chromatic, Percy, BackstopJS, and Storybook visual tests. Use when writing, running, or improving tests with visual regression testing.
Scanned 9/8/2026
Install to Claude Code
npx -y skills add anubhavg-icpl/vibe --skill visual-regression-testing --agent claude-codeInstalls into .claude/skills of the current project.
Are you the author of Visual Regression Testing?
Add the live security badge to your README — it updates automatically with every re-scan.
[](https://www.skillsdirectory.com/skills/anubhavg-icpl-visual-regression-testing)More formats (shields.io, HTML) on the badges page.
---
name: visual-regression-testing
description: Expert in visual regression testing with Playwright, Chromatic, Percy, BackstopJS, and Storybook visual tests. Use when writing, running, or improving tests with visual regression testing.
license: CC-BY-NC-SA-4.0
metadata:
risk: unknown
source: community
kind: mode
category: testing
tags: [testing, visual-regression, screenshots, playwright, chromatic, percy, backstopjs, storybook]
---
# Visual Regression Testing Mode
You are an expert in visual regression testing, helping teams catch unintended UI changes through screenshot comparison, component visual testing, and automated visual QA pipelines.
## Core Visual Testing Tools
### Playwright Visual Comparisons
```typescript
// playwright.config.ts
import { defineConfig, devices } from "@playwright/test";
export default defineConfig({
testDir: "./tests",
snapshotDir: "./tests/__snapshots__",
snapshotPathTemplate: "{snapshotDir}/{testFilePath}/{arg}{ext}",
expect: {
toHaveScreenshot: {
maxDiffPixels: 100,
maxDiffPixelRatio: 0.02,
threshold: 0.2,
animations: "disabled",
},
toMatchSnapshot: {
maxDiffPixelRatio: 0.02,
},
},
use: {
screenshot: "only-on-failure",
trace: "retain-on-failure",
},
projects: [
{
name: "chromium",
use: { ...devices["Desktop Chrome"] },
},
{
name: "firefox",
use: { ...devices["Desktop Firefox"] },
},
{
name: "webkit",
use: { ...devices["Desktop Safari"] },
},
{
name: "Mobile Chrome",
use: { ...devices["Pixel 5"] },
},
{
name: "Mobile Safari",
use: { ...devices["iPhone 12"] },
},
],
});
```
```typescript
// tests/visual/homepage.spec.ts
import { test, expect } from "@playwright/test";
test.describe("Homepage Visual Tests", () => {
test.beforeEach(async ({ page }) => {
await page.goto("/");
// Wait for fonts and images to load
await page.waitForLoadState("networkidle");
// Disable animations for consistent screenshots
await page.addStyleTag({
content: `
*, *::before, *::after {
animation-duration: 0s !important;
animation-delay: 0s !important;
transition-duration: 0s !important;
transition-delay: 0s !important;
}
`,
});
});
test("full page screenshot", async ({ page }) => {
await expect(page).toHaveScreenshot("homepage-full.png", {
fullPage: true,
});
});
test("hero section", async ({ page }) => {
const hero = page.locator('[data-testid="hero-section"]');
await expect(hero).toHaveScreenshot("hero-section.png");
});
test("navigation bar", async ({ page }) => {
const nav = page.locator("nav");
await expect(nav).toHaveScreenshot("navigation.png");
});
test("responsive layouts", async ({ page }) => {
const viewports = [
{ width: 375, height: 667, name: "mobile" },
{ width: 768, height: 1024, name: "tablet" },
{ width: 1280, height: 720, name: "desktop" },
{ width: 1920, height: 1080, name: "wide" },
];
for (const viewport of viewports) {
await page.setViewportSize({ width: viewport.width, height: viewport.height });
await expect(page).toHaveScreenshot(`homepage-${viewport.name}.png`, {
fullPage: true,
});
}
});
});
```
```typescript
// tests/visual/components.spec.ts
import { test, expect } from "@playwright/test";
test.describe("Component Visual Tests", () => {
test("button states", async ({ page }) => {
await page.goto("/components/buttons");
const button = page.getByRole("button", { name: "Primary Button" });
// Default state
await expect(button).toHaveScreenshot("button-default.png");
// Hover state
await button.hover();
await expect(button).toHaveScreenshot("button-hover.png");
// Focus state
await button.focus();
await expect(button).toHaveScreenshot("button-focus.png");
// Active state
await button.click({ force: true, noWaitAfter: true });
await expect(button).toHaveScreenshot("button-active.png");
});
test("form validation states", async ({ page }) => {
await page.goto("/components/forms");
const form = page.locator('[data-testid="contact-form"]');
// Empty state
await expect(form).toHaveScreenshot("form-empty.png");
// Filled state
await page.fill('[name="email"]', "test@example.com");
await page.fill('[name="message"]', "Hello world");
await expect(form).toHaveScreenshot("form-filled.png");
// Error state
await page.fill('[name="email"]', "invalid-email");
await page.click('button[type="submit"]');
await expect(form).toHaveScreenshot("form-error.png");
// Success state
await page.fill('[name="email"]', "valid@example.com");
await page.click('button[type="submit"]');
await page.waitForSelector(".success-message");
await expect(form).toHaveScreenshot("form-success.png");
});
test("modal dialog", async ({ page }) => {
await page.goto("/components/modals");
// Open modal
await page.click('[data-testid="open-modal"]');
await page.waitForSelector('[role="dialog"]');
// Capture modal with backdrop
await expect(page).toHaveScreenshot("modal-open.png");
// Capture just the modal
const modal = page.locator('[role="dialog"]');
await expect(modal).toHaveScreenshot("modal-content.png");
});
});
```
```typescript
// tests/visual/utils/visual-helpers.ts
import { Page, expect } from "@playwright/test";
export async function prepareForVisualTest(page: Page) {
// Wait for all images to load
await page.waitForFunction(() => {
const images = document.querySelectorAll("img");
return Array.from(images).every((img) => img.complete);
});
// Wait for custom fonts
await page.evaluate(() => document.fonts.ready);
// Disable animations
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;
}
`,
});
// Hide dynamic content
await page.addStyleTag({
content: `
[data-visual-hide="true"],
.timestamp,
.relative-time {
visibility: hidden !important;
}
`,
});
}
export async function maskDynamicContent(page: Page, selectors: string[]) {
for (const selector of selectors) {
await page.locator(selector).evaluateAll((elements) => {
elements.forEach((el) => {
(el as HTMLElement).style.visibility = "hidden";
});
});
}
}
export async function takeResponsiveScreenshots(page: Page, name: string, options?: { fullPage?: boolean }) {
const breakpoints = {
mobile: { width: 375, height: 812 },
tablet: { width: 768, height: 1024 },
desktop: { width: 1440, height: 900 },
};
const screenshots: Record<string, Buffer> = {};
for (const [device, size] of Object.entries(breakpoints)) {
await page.setViewportSize(size);
await expect(page).toHaveScreenshot(`${name}-${device}.png`, {
fullPage: options?.fullPage ?? false,
});
}
return screenshots;
}
```
### Chromatic (Storybook Visual Testing)
```javascript
// .storybook/main.js
module.exports = {
stories: ["../src/**/*.stories.@(js|jsx|ts|tsx|mdx)"],
addons: ["@storybook/addon-essentials", "@chromatic-com/storybook"],
framework: {
name: "@storybook/react-vite",
options: {},
},
};
```
```typescript
// src/components/Button/Button.stories.tsx
import type { Meta, StoryObj } from '@storybook/react';
import { Button } from './Button';
const meta: Meta<typeof Button> = {
title: 'Components/Button',
component: Button,
parameters: {
chromatic: {
viewports: [320, 768, 1200],
delay: 300,
},
},
argTypes: {
variant: {
control: 'select',
options: ['primary', 'secondary', 'danger', 'ghost'],
},
size: {
control: 'select',
options: ['sm', 'md', 'lg'],
},
},
};
export default meta;
type Story = StoryObj<typeof Button>;
export const Primary: Story = {
args: {
variant: 'primary',
children: 'Primary Button',
},
};
export const AllVariants: Story = {
render: () => (
<div style={{ display: 'flex', gap: '1rem', flexWrap: 'wrap' }}>
<Button variant="primary">Primary</Button>
<Button variant="secondary">Secondary</Button>
<Button variant="danger">Danger</Button>
<Button variant="ghost">Ghost</Button>
</div>
),
};
export const AllSizes: Story = {
render: () => (
<div style={{ display: 'flex', gap: '1rem', alignItems: 'center' }}>
<Button size="sm">Small</Button>
<Button size="md">Medium</Button>
<Button size="lg">Large</Button>
</div>
),
};
// Interactive states for visual testing
export const States: Story = {
render: () => (
<div style={{ display: 'flex', flexDirection: 'column', gap: '1rem' }}>
<Button>Default</Button>
<Button data-hover>Hover (simulated)</Button>
<Button data-focus>Focus (simulated)</Button>
<Button disabled>Disabled</Button>
<Button data-loading>Loading</Button>
</div>
),
parameters: {
pseudo: {
hover: ['[data-hover]'],
focus: ['[data-focus]'],
},
},
};
// Dark mode variant
export const DarkMode: Story = {
args: {
children: 'Dark Mode Button',
},
parameters: {
backgrounds: { default: 'dark' },
chromatic: { theme: 'dark' },
},
decorators: [
(Story) => (
<div data-theme="dark" style={{ padding: '2rem' }}>
<Story />
</div>
),
],
};
```
```typescript
// src/components/Card/Card.stories.tsx
import type { Meta, StoryObj } from '@storybook/react';
import { Card } from './Card';
const meta: Meta<typeof Card> = {
title: 'Components/Card',
component: Card,
parameters: {
chromatic: {
viewports: [320, 768, 1200],
modes: {
light: { theme: 'light' },
dark: { theme: 'dark' },
},
},
},
};
export default meta;
type Story = StoryObj<typeof Card>;
export const Default: Story = {
args: {
title: 'Card Title',
description: 'This is a card description with some text content.',
image: '/placeholder-image.jpg',
},
};
export const WithActions: Story = {
args: {
title: 'Interactive Card',
description: 'Card with action buttons.',
actions: [
{ label: 'Learn More', onClick: () => {} },
{ label: 'Dismiss', variant: 'ghost', onClick: () => {} },
],
},
};
// Snapshot test with specific configuration
export const GridLayout: Story = {
render: () => (
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(3, 1fr)', gap: '1rem' }}>
{[1, 2, 3, 4, 5, 6].map((i) => (
<Card
key={i}
title={`Card ${i}`}
description="Grid card example"
/>
))}
</div>
),
parameters: {
chromatic: {
viewports: [1200],
delay: 500,
},
},
};
```
```yaml
# chromatic.yml - GitHub Actions workflow
name: Chromatic
on:
push:
branches: [main]
pull_request:
branches: [main]
jobs:
chromatic:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- uses: actions/setup-node@v4
with:
node-version: "20"
cache: "npm"
- name: Install dependencies
run: npm ci
- name: Build Storybook
run: npm run build-storybook
- name: Run Chromatic
uses: chromaui/action@latest
with:
projectToken: ${{ secrets.CHROMATIC_PROJECT_TOKEN }}
buildScriptName: build-storybook
autoAcceptChanges: main
exitOnceUploaded: true
onlyChanged: true
```
### Percy Visual Testing
```typescript
// percy.config.js
module.exports = {
version: 2,
snapshot: {
widths: [375, 768, 1280],
minHeight: 1024,
percyCSS: `
.dynamic-content { visibility: hidden !important; }
.timestamp { opacity: 0 !important; }
`,
},
discovery: {
allowedHostnames: ["localhost", "cdn.example.com"],
networkIdleTimeout: 250,
},
upload: {
files: "**/*.html",
ignore: "**/node_modules/**",
},
};
```
```typescript
// tests/percy/visual.spec.ts
import { test } from "@playwright/test";
import percySnapshot from "@percy/playwright";
test.describe("Percy Visual Tests", () => {
test("homepage", async ({ page }) => {
await page.goto("/");
await page.waitForLoadState("networkidle");
await percySnapshot(page, "Homepage", {
widths: [375, 768, 1280],
minHeight: 1024,
});
});
test("product listing", async ({ page }) => {
await page.goto("/products");
await page.waitForSelector('[data-testid="product-grid"]');
await percySnapshot(page, "Product Listing");
});
test("product detail", async ({ page }) => {
await page.goto("/products/sample-product");
await page.waitForSelector('[data-testid="product-detail"]');
// Wait for images
await page.waitForFunction(() => {
const images = document.querySelectorAll("img");
return Array.from(images).every((img) => img.complete);
});
await percySnapshot(page, "Product Detail");
});
test("checkout flow", async ({ page }) => {
await page.goto("/checkout");
// Step 1: Cart
await percySnapshot(page, "Checkout - Cart");
// Step 2: Shipping
await page.click('[data-testid="continue-to-shipping"]');
await page.waitForSelector('[data-testid="shipping-form"]');
await percySnapshot(page, "Checkout - Shipping");
// Step 3: Payment
await page.fill('[name="address"]', "123 Test St");
await page.click('[data-testid="continue-to-payment"]');
await page.waitForSelector('[data-testid="payment-form"]');
await percySnapshot(page, "Checkout - Payment");
});
});
```
```typescript
// tests/percy/storybook.ts
import PercyStorybook from "@percy/storybook";
// Run Percy on Storybook
// npx percy storybook http://localhost:6006
// percy.storybook.config.js
module.exports = {
include: ["Components/**", "Pages/**"],
exclude: ["**/Docs", "**/*-dev"],
args: {
widths: [375, 768, 1280],
},
// Per-story configuration via parameters
// In stories: parameters: { percy: { skip: true } }
};
```
```yaml
# .github/workflows/percy.yml
name: Percy Visual Tests
on:
push:
branches: [main]
pull_request:
jobs:
percy:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: "20"
- name: Install dependencies
run: npm ci
- name: Build app
run: npm run build
- name: Start server
run: npm run start &
- name: Wait for server
run: npx wait-on http://localhost:3000
- name: Percy Test
run: npx percy exec -- npx playwright test tests/percy/
env:
PERCY_TOKEN: ${{ secrets.PERCY_TOKEN }}
```
### BackstopJS
```javascript
// backstop.config.js
module.exports = {
id: "visual-regression",
viewports: [
{ label: "phone", width: 375, height: 812 },
{ label: "tablet", width: 768, height: 1024 },
{ label: "desktop", width: 1440, height: 900 },
],
scenarios: [
{
label: "Homepage",
url: "http://localhost:3000",
selectors: ["document"],
delay: 500,
misMatchThreshold: 0.1,
requireSameDimensions: true,
},
{
label: "Homepage - Hero Section",
url: "http://localhost:3000",
selectors: ['[data-testid="hero"]'],
delay: 300,
},
{
label: "Homepage - Navigation Hover",
url: "http://localhost:3000",
selectors: ["nav"],
hoverSelector: "nav a:first-child",
delay: 200,
},
{
label: "Products Page",
url: "http://localhost:3000/products",
selectors: ["document"],
delay: 1000,
scrollToSelector: '[data-testid="product-grid"]',
},
{
label: "Modal Open",
url: "http://localhost:3000",
selectors: ["document"],
clickSelector: '[data-testid="open-modal"]',
postInteractionWait: 500,
},
{
label: "Form Validation",
url: "http://localhost:3000/contact",
selectors: ["form"],
onReadyScript: "form-validation.js",
},
],
paths: {
bitmaps_reference: "backstop_data/bitmaps_reference",
bitmaps_test: "backstop_data/bitmaps_test",
engine_scripts: "backstop_data/engine_scripts",
html_report: "backstop_data/html_report",
ci_report: "backstop_data/ci_report",
},
report: ["browser", "CI"],
engine: "playwright",
engineOptions: {
browser: "chromium",
args: ["--no-sandbox"],
},
asyncCaptureLimit: 5,
asyncCompareLimit: 50,
debug: false,
debugWindow: false,
};
```
```javascript
// backstop_data/engine_scripts/form-validation.js
module.exports = async (page, scenario) => {
// Fill form with invalid data
await page.fill('[name="email"]', "invalid-email");
await page.fill('[name="phone"]', "abc");
// Submit to trigger validation
await page.click('button[type="submit"]');
// Wait for validation messages
await page.waitForSelector(".error-message");
};
```
```javascript
// backstop_data/engine_scripts/puppet/onReady.js
module.exports = async (page, scenario, viewport, isReference) => {
// Disable animations
await page.addStyleTag({
content: `
*, *::before, *::after {
animation-duration: 0s !important;
transition-duration: 0s !important;
}
`,
});
// Wait for fonts
await page.evaluate(() => document.fonts.ready);
// Wait for images
await page.waitForFunction(() => {
const images = document.querySelectorAll("img");
return Array.from(images).every((img) => img.complete);
});
// Hide dynamic content
const dynamicSelectors = [".timestamp", ".user-avatar", ".random-ad"];
for (const selector of dynamicSelectors) {
await page.evaluate((sel) => {
document.querySelectorAll(sel).forEach((el) => {
el.style.visibility = "hidden";
});
}, selector);
}
};
```
```json
// package.json scripts for BackstopJS
{
"scripts": {
"backstop:reference": "backstop reference --config=backstop.config.js",
"backstop:test": "backstop test --config=backstop.config.js",
"backstop:approve": "backstop approve --config=backstop.config.js",
"backstop:report": "backstop openReport --config=backstop.config.js"
}
}
```
### Storybook Test Runner with Visual Tests
```typescript
// .storybook/test-runner.ts
import type { TestRunnerConfig } from "@storybook/test-runner";
import { toMatchImageSnapshot } from "jest-image-snapshot";
const config: TestRunnerConfig = {
setup() {
expect.extend({ toMatchImageSnapshot });
},
async postVisit(page, context) {
// Wait for story to be fully rendered
await page.waitForLoadState("networkidle");
// Disable animations
await page.addStyleTag({
content: `
* {
animation: none !important;
transition: none !important;
}
`,
});
// Take screenshot
const image = await page.screenshot();
expect(image).toMatchImageSnapshot({
customSnapshotsDir: `__snapshots__/${context.id}`,
customSnapshotIdentifier: context.name,
failureThreshold: 0.01,
failureThresholdType: "percent",
});
},
};
export default config;
```
```typescript
// .storybook/preview.ts
import type { Preview } from '@storybook/react';
const preview: Preview = {
parameters: {
// Global visual test configuration
chromatic: {
// Disable chromatic for docs pages
disableSnapshot: true,
},
// Default viewport sizes
viewport: {
viewports: {
mobile: { name: 'Mobile', styles: { width: '375px', height: '812px' } },
tablet: { name: 'Tablet', styles: { width: '768px', height: '1024px' } },
desktop: { name: 'Desktop', styles: { width: '1440px', height: '900px' } },
},
},
},
decorators: [
(Story, context) => {
// Ensure consistent rendering for visual tests
return (
<div style={{ padding: '1rem' }}>
<Story />
</div>
);
},
],
};
export default preview;
```
## CI/CD Integration
### GitHub Actions Complete Workflow
```yaml
# .github/workflows/visual-tests.yml
name: Visual Regression Tests
on:
push:
branches: [main, develop]
pull_request:
branches: [main]
jobs:
playwright-visual:
name: Playwright Visual Tests
runs-on: ubuntu-latest
container:
image: mcr.microsoft.com/playwright:v1.40.0-jammy
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: "20"
cache: "npm"
- name: Install dependencies
run: npm ci
- name: Build application
run: npm run build
- name: Start server
run: npm run start &
env:
PORT: 3000
- name: Wait for server
run: npx wait-on http://localhost:3000 --timeout 60000
- name: Run visual tests
run: npx playwright test --project=visual
env:
CI: true
- name: Upload visual diff artifacts
if: failure()
uses: actions/upload-artifact@v4
with:
name: visual-diff-report
path: |
test-results/
playwright-report/
retention-days: 7
- name: Update snapshots (on main only)
if: github.ref == 'refs/heads/main' && failure()
run: |
npx playwright test --update-snapshots
git config user.name "GitHub Actions"
git config user.email "actions@github.com"
git add "**/__snapshots__/**"
git commit -m "chore: update visual snapshots [skip ci]" || exit 0
git push
backstop-visual:
name: BackstopJS Visual Tests
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: "20"
- name: Install dependencies
run: npm ci
- name: Build application
run: npm run build
- name: Start server
run: npm run start &
- name: Wait for server
run: npx wait-on http://localhost:3000
- name: Run BackstopJS tests
run: npm run backstop:test
- name: Upload BackstopJS report
if: always()
uses: actions/upload-artifact@v4
with:
name: backstop-report
path: backstop_data/html_report/
retention-days: 7
chromatic:
name: Chromatic Visual Tests
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- uses: actions/setup-node@v4
with:
node-version: "20"
- name: Install dependencies
run: npm ci
- name: Publish to Chromatic
uses: chromaui/action@latest
with:
projectToken: ${{ secrets.CHROMATIC_PROJECT_TOKEN }}
buildScriptName: build-storybook
autoAcceptChanges: main
exitZeroOnChanges: true
onlyChanged: true
```
### Docker Setup for Consistent Rendering
```dockerfile
# Dockerfile.visual-tests
FROM mcr.microsoft.com/playwright:v1.40.0-jammy
WORKDIR /app
# Install fonts for consistent rendering
RUN apt-get update && apt-get install -y \
fonts-liberation \
fonts-noto-cjk \
fonts-freefont-ttf \
&& rm -rf /var/lib/apt/lists/*
# Copy package files
COPY package*.json ./
RUN npm ci
# Copy source
COPY . .
# Build application
RUN npm run build
# Run tests
CMD ["npm", "run", "test:visual"]
```
```yaml
# docker-compose.visual.yml
version: "3.8"
services:
app:
build:
context: .
dockerfile: Dockerfile
ports:
- "3000:3000"
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:3000"]
interval: 5s
timeout: 3s
retries: 10
visual-tests:
build:
context: .
dockerfile: Dockerfile.visual-tests
depends_on:
app:
condition: service_healthy
environment:
- BASE_URL=http://app:3000
volumes:
- ./test-results:/app/test-results
- ./playwright-report:/app/playwright-report
```
## Advanced Patterns
### Component Visual Testing Matrix
```typescript
// tests/visual/component-matrix.spec.ts
import { test, expect } from "@playwright/test";
interface ComponentVariant {
props: Record<string, unknown>;
name: string;
}
interface ComponentTestConfig {
component: string;
url: string;
selector: string;
variants: ComponentVariant[];
states?: ("default" | "hover" | "focus" | "active" | "disabled")[];
themes?: string[];
viewports?: { width: number; height: number; name: string }[];
}
const components: ComponentTestConfig[] = [
{
component: "Button",
url: "/storybook-static/iframe.html?id=components-button--default",
selector: "button",
variants: [
{ name: "primary", props: { variant: "primary" } },
{ name: "secondary", props: { variant: "secondary" } },
{ name: "danger", props: { variant: "danger" } },
],
states: ["default", "hover", "focus", "disabled"],
themes: ["light", "dark"],
},
{
component: "Input",
url: "/storybook-static/iframe.html?id=components-input--default",
selector: "input",
variants: [
{ name: "text", props: { type: "text" } },
{ name: "password", props: { type: "password" } },
{ name: "search", props: { type: "search" } },
],
states: ["default", "focus", "disabled"],
themes: ["light", "dark"],
},
];
for (const config of components) {
test.describe(`${config.component} Visual Matrix`, () => {
for (const variant of config.variants) {
for (const theme of config.themes || ["light"]) {
for (const state of config.states || ["default"]) {
test(`${variant.name} - ${theme} - ${state}`, async ({ page }) => {
// Navigate to component
const url = new URL(config.url, "http://localhost:6006");
Object.entries(variant.props).forEach(([key, value]) => {
url.searchParams.set(`args.${key}`, String(value));
});
await page.goto(url.toString());
// Apply theme
await page.evaluate((t) => {
document.documentElement.setAttribute("data-theme", t);
}, theme);
const element = page.locator(config.selector).first();
// Apply state
switch (state) {
case "hover":
await element.hover();
break;
case "focus":
await element.focus();
break;
case "disabled":
await element.evaluate((el) => {
(el as HTMLButtonElement).disabled = true;
});
break;
}
// Take screenshot
await expect(element).toHaveScreenshot(`${config.component}-${variant.name}-${theme}-${state}.png`);
});
}
}
}
});
}
```
### Responsive Visual Regression
```typescript
// tests/visual/responsive.spec.ts
import { test, expect, devices } from "@playwright/test";
const pages = [
{ name: "homepage", url: "/" },
{ name: "products", url: "/products" },
{ name: "about", url: "/about" },
{ name: "contact", url: "/contact" },
];
const deviceList = ["iPhone 12", "iPhone 12 Pro Max", "iPad", "iPad Pro 11", "Desktop Chrome", "Desktop Firefox"];
for (const pageConfig of pages) {
test.describe(`${pageConfig.name} Responsive`, () => {
for (const deviceName of deviceList) {
test(`${deviceName}`, async ({ browser }) => {
const device = devices[deviceName];
const context = await browser.newContext({
...device,
});
const page = await context.newPage();
await page.goto(pageConfig.url);
await page.waitForLoadState("networkidle");
await expect(page).toHaveScreenshot(`${pageConfig.name}-${deviceName.replace(/\s+/g, "-").toLowerCase()}.png`, {
fullPage: true,
});
await context.close();
});
}
});
}
```
### Visual Testing with Themes
```typescript
// tests/visual/themes.spec.ts
import { test, expect } from "@playwright/test";
const themes = ["light", "dark", "high-contrast", "system"];
const pages = ["/", "/dashboard", "/settings", "/profile"];
test.describe("Theme Visual Tests", () => {
for (const theme of themes) {
for (const pagePath of pages) {
test(`${pagePath} - ${theme} theme`, async ({ page }) => {
// Set color scheme for system theme
if (theme === "system") {
await page.emulateMedia({ colorScheme: "dark" });
}
await page.goto(pagePath);
// Apply theme
if (theme !== "system") {
await page.evaluate((t) => {
localStorage.setItem("theme", t);
document.documentElement.setAttribute("data-theme", t);
}, theme);
}
// Wait for theme to apply
await page.waitForTimeout(100);
await page.waitForLoadState("networkidle");
const pageName = pagePath === "/" ? "home" : pagePath.slice(1);
await expect(page).toHaveScreenshot(`${pageName}-${theme}.png`, { fullPage: true });
});
}
}
});
```
## Best Practices
1. **Consistent Environment**
- Use Docker for reproducible screenshots
- Pin browser versions in CI
- Disable animations during tests
- Wait for fonts and images to load
2. **Threshold Configuration**
- Start with strict thresholds (0.1%)
- Adjust per-component as needed
- Document threshold reasoning
- Use pixel count for small elements
3. **Handling Dynamic Content**
- Mask timestamps and avatars
- Mock API responses
- Use consistent test data
- Hide ads and third-party widgets
4. **Performance**
- Run tests in parallel
- Use sharding for large suites
- Cache dependencies
- Only test critical paths
5. **Maintenance**
- Review failed tests promptly
- Update baselines intentionally
- Document visual changes
- Prune unused snapshots
6. **Cross-Browser Testing**
- Test primary browsers (Chrome, Firefox, Safari)
- Include mobile viewports
- Test RTL layouts if applicable
- Verify font rendering consistency
Is this your skill, or is something wrong with this listing? Request removal or report an issue. Author removals are honored within 72 hours.
No comments yet. Be the first to comment!