visual-regression-testing

Compare original and translation side by side

🇺🇸

Original

English
🇨🇳

Translation

Chinese

Visual Regression Testing

视觉回归测试

Overview

概述

Visual regression testing captures screenshots of UI components and pages, then compares them across versions to detect unintended visual changes. This automated approach catches CSS bugs, layout issues, and design regressions that traditional functional tests miss.
视觉回归测试会捕获UI组件和页面的截图,然后跨版本对比这些截图,以检测意外的视觉变化。这种自动化方法能够发现传统功能测试遗漏的CSS漏洞、布局问题和设计回归。

When to Use

适用场景

  • Detecting CSS regression bugs
  • Validating responsive design across viewports
  • Testing across different browsers
  • Verifying component visual consistency
  • Catching layout shifts and overlaps
  • Testing theme changes
  • Validating design system components
  • Reviewing visual changes in PRs
  • 检测CSS回归漏洞
  • 验证不同视口下的响应式设计
  • 跨不同浏览器测试
  • 验证组件视觉一致性
  • 捕获布局偏移与重叠问题
  • 测试主题变更
  • 验证设计系统组件
  • 审查PR中的视觉变更

Key Concepts

核心概念

  • Baseline: Reference screenshot (approved version)
  • Comparison: New screenshot to compare against baseline
  • Diff: Visual difference between baseline and comparison
  • Threshold: Acceptable difference percentage
  • Snapshot: Captured UI state at specific viewport
  • Approval: Accepting new baseline after intentional changes
  • Baseline(基准线):参考截图(已批准版本)
  • Comparison(对比图):与基准线对比的新截图
  • Diff(差异图):基准线与对比图之间的视觉差异
  • Threshold(阈值):可接受的差异百分比
  • Snapshot(快照):特定视口下捕获的UI状态
  • Approval(批准):在有意变更后接受新的基准线

Instructions

操作指南

1. Playwright Visual Testing

1. Playwright 视觉测试

typescript
// tests/visual/homepage.spec.ts
import { test, expect } from '@playwright/test';

test.describe('Homepage Visual Tests', () => {
  test('homepage matches baseline', async ({ page }) => {
    await page.goto('/');

    // Wait for images to load
    await page.waitForLoadState('networkidle');

    // Full page screenshot
    await expect(page).toHaveScreenshot('homepage-full.png', {
      fullPage: true,
      maxDiffPixels: 100,  // Allow small differences
    });
  });

  test('responsive design - mobile', async ({ page }) => {
    await page.setViewportSize({ width: 375, height: 667 }); // iPhone SE
    await page.goto('/');

    await expect(page).toHaveScreenshot('homepage-mobile.png');
  });

  test('responsive design - tablet', async ({ page }) => {
    await page.setViewportSize({ width: 768, height: 1024 }); // iPad
    await page.goto('/');

    await expect(page).toHaveScreenshot('homepage-tablet.png');
  });

  test('responsive design - desktop', async ({ page }) => {
    await page.setViewportSize({ width: 1920, height: 1080 });
    await page.goto('/');

    await expect(page).toHaveScreenshot('homepage-desktop.png');
  });

  test('dark mode visual', async ({ page }) => {
    await page.goto('/');
    await page.emulateMedia({ colorScheme: 'dark' });
    await page.waitForTimeout(500); // Allow theme transition

    await expect(page).toHaveScreenshot('homepage-dark.png');
  });

  test('component visual - hero section', async ({ page }) => {
    await page.goto('/');

    const hero = page.locator('[data-testid="hero-section"]');
    await expect(hero).toHaveScreenshot('hero-section.png');
  });

  test('interactive state - button hover', async ({ page }) => {
    await page.goto('/');

    const button = page.locator('button.primary');
    await button.hover();
    await page.waitForTimeout(200); // Allow hover animation

    await expect(button).toHaveScreenshot('button-hover.png');
  });
});

// playwright.config.ts
import { defineConfig } from '@playwright/test';

export default defineConfig({
  expect: {
    toHaveScreenshot: {
      maxDiffPixels: 50,           // Maximum different pixels
      threshold: 0.2,              // 20% threshold
      animations: 'disabled',       // Disable animations for consistency
    },
  },
  use: {
    screenshot: 'only-on-failure',
  },
});
typescript
// tests/visual/homepage.spec.ts
import { test, expect } from '@playwright/test';

test.describe('Homepage Visual Tests', () => {
  test('homepage matches baseline', async ({ page }) => {
    await page.goto('/');

    // Wait for images to load
    await page.waitForLoadState('networkidle');

    // Full page screenshot
    await expect(page).toHaveScreenshot('homepage-full.png', {
      fullPage: true,
      maxDiffPixels: 100,  // Allow small differences
    });
  });

  test('responsive design - mobile', async ({ page }) => {
    await page.setViewportSize({ width: 375, height: 667 }); // iPhone SE
    await page.goto('/');

    await expect(page).toHaveScreenshot('homepage-mobile.png');
  });

  test('responsive design - tablet', async ({ page }) => {
    await page.setViewportSize({ width: 768, height: 1024 }); // iPad
    await page.goto('/');

    await expect(page).toHaveScreenshot('homepage-tablet.png');
  });

  test('responsive design - desktop', async ({ page }) => {
    await page.setViewportSize({ width: 1920, height: 1080 });
    await page.goto('/');

    await expect(page).toHaveScreenshot('homepage-desktop.png');
  });

  test('dark mode visual', async ({ page }) => {
    await page.goto('/');
    await page.emulateMedia({ colorScheme: 'dark' });
    await page.waitForTimeout(500); // Allow theme transition

    await expect(page).toHaveScreenshot('homepage-dark.png');
  });

  test('component visual - hero section', async ({ page }) => {
    await page.goto('/');

    const hero = page.locator('[data-testid="hero-section"]');
    await expect(hero).toHaveScreenshot('hero-section.png');
  });

  test('interactive state - button hover', async ({ page }) => {
    await page.goto('/');

    const button = page.locator('button.primary');
    await button.hover();
    await page.waitForTimeout(200); // Allow hover animation

    await expect(button).toHaveScreenshot('button-hover.png');
  });
});

// playwright.config.ts
import { defineConfig } from '@playwright/test';

export default defineConfig({
  expect: {
    toHaveScreenshot: {
      maxDiffPixels: 50,           // Maximum different pixels
      threshold: 0.2,              // 20% threshold
      animations: 'disabled',       // Disable animations for consistency
    },
  },
  use: {
    screenshot: 'only-on-failure',
  },
});

2. Percy Visual Testing

2. Percy 视觉测试

typescript
// tests/visual-percy.spec.ts
import { test } from '@playwright/test';
import percySnapshot from '@percy/playwright';

test.describe('Percy Visual Tests', () => {
  test('homepage across viewports', async ({ page }) => {
    await page.goto('/');

    // Percy automatically tests across configured viewports
    await percySnapshot(page, 'Homepage');
  });

  test('product page variations', async ({ page }) => {
    await page.goto('/products/123');

    // Test different states
    await percySnapshot(page, 'Product Page - Default');

    // Open modal
    await page.click('[data-testid="size-guide"]');
    await percySnapshot(page, 'Product Page - Size Guide Modal');

    // Add to cart
    await page.click('[data-testid="add-to-cart"]');
    await percySnapshot(page, 'Product Page - Added to Cart');
  });

  test('component library', async ({ page }) => {
    await page.goto('/styleguide');

    // Test individual components
    const components = ['buttons', 'forms', 'cards', 'modals'];

    for (const component of components) {
      await page.click(`[data-component="${component}"]`);
      await percySnapshot(page, `Component - ${component}`);
    }
  });
});

// percy.config.yml
version: 2
snapshot:
  widths: [375, 768, 1280, 1920]
  min-height: 1024
  percy-css: |
    /* Hide dynamic content */
    .timestamp { visibility: hidden; }
    .ad-banner { display: none; }
typescript
// tests/visual-percy.spec.ts
import { test } from '@playwright/test';
import percySnapshot from '@percy/playwright';

test.describe('Percy Visual Tests', () => {
  test('homepage across viewports', async ({ page }) => {
    await page.goto('/');

    // Percy automatically tests across configured viewports
    await percySnapshot(page, 'Homepage');
  });

  test('product page variations', async ({ page }) => {
    await page.goto('/products/123');

    // Test different states
    await percySnapshot(page, 'Product Page - Default');

    // Open modal
    await page.click('[data-testid="size-guide"]');
    await percySnapshot(page, 'Product Page - Size Guide Modal');

    // Add to cart
    await page.click('[data-testid="add-to-cart"]');
    await percySnapshot(page, 'Product Page - Added to Cart');
  });

  test('component library', async ({ page }) => {
    await page.goto('/styleguide');

    // Test individual components
    const components = ['buttons', 'forms', 'cards', 'modals'];

    for (const component of components) {
      await page.click(`[data-component="${component}"]`);
      await percySnapshot(page, `Component - ${component}`);
    }
  });
});

// percy.config.yml
version: 2
snapshot:
  widths: [375, 768, 1280, 1920]
  min-height: 1024
  percy-css: |
    /* Hide dynamic content */
    .timestamp { visibility: hidden; }
    .ad-banner { display: none; }

3. Chromatic for Storybook

3. Chromatic for Storybook

typescript
// .storybook/main.ts
export default {
  addons: ['@storybook/addon-essentials'],
  framework: '@storybook/react',
};

// 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],  // Test responsive
      delay: 300,                    // Wait for animations
    },
  },
};

export default meta;
type Story = StoryObj<typeof Button>;

export const Primary: Story = {
  args: {
    variant: 'primary',
    children: 'Primary Button',
  },
};

export const Secondary: Story = {
  args: {
    variant: 'secondary',
    children: 'Secondary Button',
  },
};

export const Disabled: Story = {
  args: {
    variant: 'primary',
    disabled: true,
    children: 'Disabled Button',
  },
};

export const WithIcon: Story = {
  args: {
    children: (
      <>
        <Icon name="arrow-right" /> Continue
      </>
    ),
  },
};

// Test hover states
export const HoverState: Story = {
  args: {
    variant: 'primary',
    children: 'Hover Me',
  },
  parameters: {
    pseudo: { hover: true },
  },
};

// Test focus states
export const FocusState: Story = {
  args: {
    variant: 'primary',
    children: 'Focus Me',
  },
  parameters: {
    pseudo: { focus: true },
  },
};
bash
undefined
typescript
// .storybook/main.ts
export default {
  addons: ['@storybook/addon-essentials'],
  framework: '@storybook/react',
};

// 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],  // Test responsive
      delay: 300,                    // Wait for animations
    },
  },
};

export default meta;
type Story = StoryObj<typeof Button>;

export const Primary: Story = {
  args: {
    variant: 'primary',
    children: 'Primary Button',
  },
};

export const Secondary: Story = {
  args: {
    variant: 'secondary',
    children: 'Secondary Button',
  },
};

export const Disabled: Story = {
  args: {
    variant: 'primary',
    disabled: true,
    children: 'Disabled Button',
  },
};

export const WithIcon: Story = {
  args: {
    children: (
      <>
        <Icon name="arrow-right" /> Continue
      </>
    ),
  },
};

// Test hover states
export const HoverState: Story = {
  args: {
    variant: 'primary',
    children: 'Hover Me',
  },
  parameters: {
    pseudo: { hover: true },
  },
};

// Test focus states
export const FocusState: Story = {
  args: {
    variant: 'primary',
    children: 'Focus Me',
  },
  parameters: {
    pseudo: { focus: true },
  },
};
bash
undefined

Install Chromatic

Install Chromatic

npm install --save-dev chromatic
npm install --save-dev chromatic

Run visual tests

Run visual tests

npx chromatic --project-token=<TOKEN>
npx chromatic --project-token=<TOKEN>

In CI

In CI

npx chromatic --exit-zero-on-changes
undefined
npx chromatic --exit-zero-on-changes
undefined

4. Cypress Visual Testing

4. Cypress 视觉测试

javascript
// cypress/e2e/visual.cy.js
describe('Visual Regression Tests', () => {
  beforeEach(() => {
    cy.visit('/');
  });

  it('homepage visual snapshot', () => {
    cy.viewport(1280, 720);
    cy.matchImageSnapshot('homepage-desktop');
  });

  it('mobile navigation menu', () => {
    cy.viewport('iphone-x');
    cy.get('[data-cy="menu-toggle"]').click();
    cy.get('.mobile-menu').should('be.visible');
    cy.matchImageSnapshot('mobile-menu-open');
  });

  it('form validation errors', () => {
    cy.get('form').within(() => {
      cy.get('[type="email"]').type('invalid-email');
      cy.get('[type="submit"]').click();
    });

    cy.get('.error-message').should('be.visible');
    cy.matchImageSnapshot('form-validation-errors');
  });

  it('loading state', () => {
    cy.intercept('GET', '/api/products', (req) => {
      req.reply((res) => {
        res.delay(1000); // Simulate slow response
        res.send();
      });
    });

    cy.visit('/products');
    cy.matchImageSnapshot('loading-skeleton');
  });

  it('empty state', () => {
    cy.intercept('GET', '/api/cart', { items: [] });
    cy.visit('/cart');
    cy.matchImageSnapshot('cart-empty-state');
  });
});

// cypress.config.js
const { defineConfig } = require('cypress');
const {
  addMatchImageSnapshotPlugin,
} = require('cypress-image-snapshot/plugin');

module.exports = defineConfig({
  e2e: {
    setupNodeEvents(on, config) {
      addMatchImageSnapshotPlugin(on, config);
    },
  },
});

// cypress/support/commands.js
import { addMatchImageSnapshotCommand } from 'cypress-image-snapshot/command';

addMatchImageSnapshotCommand({
  failureThreshold: 0.03,        // Allow 3% difference
  failureThresholdType: 'percent',
  customDiffConfig: { threshold: 0.1 },
  capture: 'viewport',
});
javascript
// cypress/e2e/visual.cy.js
describe('Visual Regression Tests', () => {
  beforeEach(() => {
    cy.visit('/');
  });

  it('homepage visual snapshot', () => {
    cy.viewport(1280, 720);
    cy.matchImageSnapshot('homepage-desktop');
  });

  it('mobile navigation menu', () => {
    cy.viewport('iphone-x');
    cy.get('[data-cy="menu-toggle"]').click();
    cy.get('.mobile-menu').should('be.visible');
    cy.matchImageSnapshot('mobile-menu-open');
  });

  it('form validation errors', () => {
    cy.get('form').within(() => {
      cy.get('[type="email"]').type('invalid-email');
      cy.get('[type="submit"]').click();
    });

    cy.get('.error-message').should('be.visible');
    cy.matchImageSnapshot('form-validation-errors');
  });

  it('loading state', () => {
    cy.intercept('GET', '/api/products', (req) => {
      req.reply((res) => {
        res.delay(1000); // Simulate slow response
        res.send();
      });
    });

    cy.visit('/products');
    cy.matchImageSnapshot('loading-skeleton');
  });

  it('empty state', () => {
    cy.intercept('GET', '/api/cart', { items: [] });
    cy.visit('/cart');
    cy.matchImageSnapshot('cart-empty-state');
  });
});

// cypress.config.js
const { defineConfig } = require('cypress');
const {
  addMatchImageSnapshotPlugin,
} = require('cypress-image-snapshot/plugin');

module.exports = defineConfig({
  e2e: {
    setupNodeEvents(on, config) {
      addMatchImageSnapshotPlugin(on, config);
    },
  },
});

// cypress/support/commands.js
import { addMatchImageSnapshotCommand } from 'cypress-image-snapshot/command';

addMatchImageSnapshotCommand({
  failureThreshold: 0.03,        // Allow 3% difference
  failureThresholdType: 'percent',
  customDiffConfig: { threshold: 0.1 },
  capture: 'viewport',
});

5. BackstopJS Configuration

5. BackstopJS 配置

javascript
// backstop.config.js
module.exports = {
  id: 'visual_regression',
  viewports: [
    {
      label: 'phone',
      width: 375,
      height: 667,
    },
    {
      label: 'tablet',
      width: 768,
      height: 1024,
    },
    {
      label: 'desktop',
      width: 1920,
      height: 1080,
    },
  ],
  scenarios: [
    {
      label: 'Homepage',
      url: 'http://localhost:3000',
      delay: 500,
      misMatchThreshold: 0.1,
      requireSameDimensions: true,
    },
    {
      label: 'Product List',
      url: 'http://localhost:3000/products',
      delay: 1000,
      removeSelectors: ['.timestamp', '.ad-banner'],
    },
    {
      label: 'Product Detail',
      url: 'http://localhost:3000/products/123',
      clickSelector: '.size-guide-link',
      postInteractionWait: 500,
    },
    {
      label: 'Hover State',
      url: 'http://localhost:3000',
      hoverSelector: '.primary-button',
      postInteractionWait: 200,
    },
  ],
  paths: {
    bitmaps_reference: 'backstop_data/bitmaps_reference',
    bitmaps_test: 'backstop_data/bitmaps_test',
    html_report: 'backstop_data/html_report',
  },
  engine: 'puppeteer',
  engineOptions: {
    args: ['--no-sandbox'],
  },
  asyncCaptureLimit: 5,
  asyncCompareLimit: 50,
  debug: false,
  debugWindow: false,
};
bash
undefined
javascript
// backstop.config.js
module.exports = {
  id: 'visual_regression',
  viewports: [
    {
      label: 'phone',
      width: 375,
      height: 667,
    },
    {
      label: 'tablet',
      width: 768,
      height: 1024,
    },
    {
      label: 'desktop',
      width: 1920,
      height: 1080,
    },
  ],
  scenarios: [
    {
      label: 'Homepage',
      url: 'http://localhost:3000',
      delay: 500,
      misMatchThreshold: 0.1,
      requireSameDimensions: true,
    },
    {
      label: 'Product List',
      url: 'http://localhost:3000/products',
      delay: 1000,
      removeSelectors: ['.timestamp', '.ad-banner'],
    },
    {
      label: 'Product Detail',
      url: 'http://localhost:3000/products/123',
      clickSelector: '.size-guide-link',
      postInteractionWait: 500,
    },
    {
      label: 'Hover State',
      url: 'http://localhost:3000',
      hoverSelector: '.primary-button',
      postInteractionWait: 200,
    },
  ],
  paths: {
    bitmaps_reference: 'backstop_data/bitmaps_reference',
    bitmaps_test: 'backstop_data/bitmaps_test',
    html_report: 'backstop_data/html_report',
  },
  engine: 'puppeteer',
  engineOptions: {
    args: ['--no-sandbox'],
  },
  asyncCaptureLimit: 5,
  asyncCompareLimit: 50,
  debug: false,
  debugWindow: false,
};
bash
undefined

Create reference images

Create reference images

backstop reference
backstop reference

Run test

Run test

backstop test
backstop test

Approve changes

Approve changes

backstop approve
undefined
backstop approve
undefined

6. Handling Dynamic Content

6. 处理动态内容

typescript
// Hide or mock dynamic content
test('page with dynamic content', async ({ page }) => {
  await page.goto('/dashboard');

  // Hide timestamps
  await page.addStyleTag({
    content: '.timestamp { visibility: hidden; }'
  });

  // Mock random content
  await page.evaluate(() => {
    Math.random = () => 0.5;
    Date.now = () => 1234567890;
  });

  // Wait for animations
  await page.waitForTimeout(500);

  await expect(page).toHaveScreenshot();
});

// Ignore regions
test('ignore dynamic regions', async ({ page }) => {
  await page.goto('/');

  await expect(page).toHaveScreenshot({
    mask: [
      page.locator('.ad-banner'),
      page.locator('.live-chat'),
      page.locator('.timestamp'),
    ],
  });
});
typescript
// Hide or mock dynamic content
test('page with dynamic content', async ({ page }) => {
  await page.goto('/dashboard');

  // Hide timestamps
  await page.addStyleTag({
    content: '.timestamp { visibility: hidden; }'
  });

  // Mock random content
  await page.evaluate(() => {
    Math.random = () => 0.5;
    Date.now = () => 1234567890;
  });

  // Wait for animations
  await page.waitForTimeout(500);

  await expect(page).toHaveScreenshot();
});

// Ignore regions
test('ignore dynamic regions', async ({ page }) => {
  await page.goto('/');

  await expect(page).toHaveScreenshot({
    mask: [
      page.locator('.ad-banner'),
      page.locator('.live-chat'),
      page.locator('.timestamp'),
    ],
  });
});

7. Testing Responsive Components

7. 测试响应式组件

typescript
const viewports = [
  { name: 'mobile', width: 375, height: 667 },
  { name: 'tablet', width: 768, height: 1024 },
  { name: 'desktop', width: 1920, height: 1080 },
  { name: '4k', width: 3840, height: 2160 },
];

for (const viewport of viewports) {
  test(`navigation at ${viewport.name}`, async ({ page }) => {
    await page.setViewportSize({
      width: viewport.width,
      height: viewport.height,
    });

    await page.goto('/');

    await expect(page.locator('nav')).toHaveScreenshot(
      `nav-${viewport.name}.png`
    );
  });
}
typescript
const viewports = [
  { name: 'mobile', width: 375, height: 667 },
  { name: 'tablet', width: 768, height: 1024 },
  { name: 'desktop', width: 1920, height: 1080 },
  { name: '4k', width: 3840, height: 2160 },
];

for (const viewport of viewports) {
  test(`navigation at ${viewport.name}`, async ({ page }) => {
    await page.setViewportSize({
      width: viewport.width,
      height: viewport.height,
    });

    await page.goto('/');

    await expect(page.locator('nav')).toHaveScreenshot(
      `nav-${viewport.name}.png`
    );
  });
}

Best Practices

最佳实践

✅ DO

✅ 建议

  • Hide or mock dynamic content (timestamps, ads)
  • Test across multiple viewports
  • Wait for animations and images to load
  • Use consistent viewport sizes
  • Disable animations during capture
  • Test interactive states (hover, focus)
  • Review diffs carefully before approving
  • Store baselines in version control
  • 隐藏或模拟动态内容(时间戳、广告)
  • 跨多个视口测试
  • 等待动画和图片加载完成
  • 使用一致的视口尺寸
  • 捕获时禁用动画
  • 测试交互状态(悬停、聚焦)
  • 批准前仔细审查差异
  • 在版本控制中存储基准线

❌ DON'T

❌ 避免

  • Test pages with constantly changing content
  • Ignore small legitimate differences
  • Skip responsive testing
  • Forget to update baselines after design changes
  • Test pages with random data
  • Use overly strict thresholds (0% diff)
  • Skip browser/device variations
  • Commit unapproved diffs
  • 测试内容频繁变化的页面
  • 忽略合理的细微差异
  • 跳过响应式测试
  • 设计变更后忘记更新基准线
  • 测试包含随机数据的页面
  • 使用过于严格的阈值(0%差异)
  • 跳过浏览器/设备差异测试
  • 提交未批准的差异

Tools

工具

  • Playwright: Built-in screenshot comparison
  • Percy: Cloud-based visual testing
  • Chromatic: Storybook visual testing
  • BackstopJS: Open-source visual regression
  • cypress-image-snapshot: Cypress plugin
  • Applitools: AI-powered visual testing
  • Sauce Labs Visual: Cross-browser visual testing
  • Playwright:内置截图对比功能
  • Percy:基于云的视觉测试工具
  • Chromatic:Storybook专属视觉测试工具
  • BackstopJS:开源视觉回归测试工具
  • cypress-image-snapshot:Cypress插件
  • Applitools:AI驱动的视觉测试工具
  • Sauce Labs Visual:跨浏览器视觉测试工具

CI Integration

CI集成

yaml
undefined
yaml
undefined

.github/workflows/visual-tests.yml

.github/workflows/visual-tests.yml

name: Visual Regression Tests
on: [pull_request]
jobs: visual-tests: runs-on: ubuntu-latest steps: - uses: actions/checkout@v3 with: fetch-depth: 0 # Needed for Percy
  - uses: actions/setup-node@v3

  - run: npm ci

  - run: npm run build

  - name: Run Playwright visual tests
    run: npx playwright test --grep @visual

  - name: Upload test results
    if: always()
    uses: actions/upload-artifact@v3
    with:
      name: visual-test-results
      path: test-results/

  - name: Percy snapshots
    run: npx percy exec -- npm run test:visual
    env:
      PERCY_TOKEN: ${{ secrets.PERCY_TOKEN }}
undefined
name: Visual Regression Tests
on: [pull_request]
jobs: visual-tests: runs-on: ubuntu-latest steps: - uses: actions/checkout@v3 with: fetch-depth: 0 # Needed for Percy
  - uses: actions/setup-node@v3

  - run: npm ci

  - run: npm run build

  - name: Run Playwright visual tests
    run: npx playwright test --grep @visual

  - name: Upload test results
    if: always()
    uses: actions/upload-artifact@v3
    with:
      name: visual-test-results
      path: test-results/

  - name: Percy snapshots
    run: npx percy exec -- npm run test:visual
    env:
      PERCY_TOKEN: ${{ secrets.PERCY_TOKEN }}
undefined

Troubleshooting

故障排除

Flaky Tests

不稳定的测试

  • Ensure consistent timing (wait for network idle)
  • Disable animations
  • Mock randomness
  • Use fixed dates/times
  • 确保一致的计时(等待网络空闲)
  • 禁用动画
  • 模拟随机值
  • 使用固定日期/时间

Large Diffs

大差异问题

  • Check for font rendering differences
  • Verify image loading
  • Check for animation timing
  • Review anti-aliasing differences
  • 检查字体渲染差异
  • 验证图片是否加载完成
  • 检查动画计时
  • 审查抗锯齿差异

False Positives

误报

  • Adjust threshold tolerance
  • Mask dynamic regions
  • Use relative comparison
  • Review diff images carefully
  • 调整阈值容差
  • 遮罩动态区域
  • 使用相对对比
  • 仔细审查差异图片

Examples

相关示例

See also: e2e-testing-automation, accessibility-testing, test-automation-framework for comprehensive UI testing.
另请参阅:e2e-testing-automation、accessibility-testing、test-automation-framework,以获取全面的UI测试方案。