视觉回归测试 visual-regression-testing

视觉回归测试是一种自动化测试方法,通过比较不同版本中的用户界面截图来检测意外的视觉变化,如CSS错误、布局问题和设计回归。

测试 0 次安装 0 次浏览 更新于 3/4/2026

name: visual-regression-testing description: 通过比较不同版本中的截图来检测用户界面中意外的视觉变化。用于视觉回归测试、截图差异、Percy、Chromatic、UI测试和视觉验证。

视觉回归测试

概览

视觉回归测试捕获UI组件和页面的屏幕截图,然后跨版本比较它们以检测意外的视觉变化。这种自动化方法可以捕捉到传统功能测试遗漏的CSS错误、布局问题和设计回归。

何时使用

  • 检测CSS回归错误
  • 验证跨视口的响应式设计
  • 在不同浏览器中测试
  • 验证组件视觉一致性
  • 捕获布局偏移和重叠
  • 测试主题更改
  • 验证设计系统组件
  • 在PR中审查视觉变化

关键概念

  • 基线:参考截图(批准版本)
  • 比较:与基线比较的新截图
  • 差异:基线和比较之间的视觉差异
  • 阈值:可接受的差异百分比
  • 快照:在特定视口捕获的UI状态
  • 批准:在有意更改后接受新的基线

指令

1. Playwright视觉测试

// 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('/');

    // 等待图片加载
    await page.waitForLoadState('networkidle');

    // 全页面截图
    await expect(page).toHaveScreenshot('homepage-full.png', {
      fullPage: true,
      maxDiffPixels: 100,  // 允许小差异
    });
  });

  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); // 允许主题转换

    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); // 允许悬停动画

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

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

export default defineConfig({
  expect: {
    toHaveScreenshot: {
      maxDiffPixels: 50,           // 最大不同像素
      threshold: 0.2,              // 20%阈值
      animations: 'disabled',       // 禁用动画以保持一致性
    },
  },
  use: {
    screenshot: 'only-on-failure',
  },
});

2. Percy视觉测试

// 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自动跨配置的视口测试
    await percySnapshot(page, 'Homepage');
  });

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

    // 测试不同状态
    await percySnapshot(page, 'Product Page - Default');

    // 打开模态框
    await page.click('[data-testid="size-guide"]');
    await percySnapshot(page, 'Product Page - Size Guide Modal');

    // 添加到购物车
    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');

    // 测试单个组件
    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为Storybook

// .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],  // 测试响应性
      delay: 300,                    // 等待动画
    },
  },
};

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
      </>
    ),
  },
};

// 测试悬停状态
export const HoverState: Story = {
  args: {
    variant: 'primary',
    children: 'Hover Me',
  },
  parameters: {
    pseudo: { hover: true },
  },
};

// 测试焦点状态
export const FocusState: Story = {
  args: {
    variant: 'primary',
    children: 'Focus Me',
  },
  parameters: {
    pseudo: { focus: true },
  },
};
# 安装Chromatic
npm install --save-dev chromatic

# 运行视觉测试
npx chromatic --project-token=<TOKEN>

# 在CI中
npx chromatic --exit-zero-on-changes

4. Cypress视觉测试

// 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); // 模拟慢响应
        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,        // 允许3%差异
  failureThresholdType: 'percent',
  customDiffConfig: { threshold: 0.1 },
  capture: 'viewport',
});

5. BackstopJS配置

// 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,
};
# 创建参考图像
backstop reference

# 运行测试
backstop test

# 批准更改
backstop approve

6. 处理动态内容

// 隐藏或模拟动态内容
test('page with dynamic content', async ({ page }) => {
  await page.goto('/dashboard');

  // 隐藏时间戳
  await page.addStyleTag({
    content: '.timestamp { visibility: hidden; }'
  });

  // 模拟随机内容
  await page.evaluate(() => {
    Math.random = () => 0.5;
    Date.now = () => 1234567890;
  });

  // 等待动画
  await page.waitForTimeout(500);

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

// 忽略区域
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. 测试响应式组件

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`
    );
  });
}

最佳实践

✅ DO

  • 隐藏或模拟动态内容(时间戳、广告)
  • 跨多个视口测试
  • 等待动画和图片加载
  • 使用一致的视口大小
  • 在捕获期间禁用动画
  • 测试交互状态(悬停、焦点)
  • 在批准前仔细审查差异
  • 将基线存储在版本控制中

❌ DON’T

  • 测试不断变化内容的页面
  • 忽略小的合法差异
  • 跳过响应式测试
  • 忘记在设计更改后更新基线
  • 测试带有随机数据的页面
  • 使用过于严格的阈值(0%差异)
  • 跳过浏览器/设备变体
  • 提交未批准的差异

工具

  • Playwright:内置截图比较
  • Percy:基于云的视觉测试
  • Chromatic:Storybook视觉测试
  • BackstopJS:开源视觉回归
  • cypress-image-snapshot:Cypress插件
  • Applitools:AI驱动的视觉测试
  • Sauce Labs Visual:跨浏览器视觉测试

CI集成

# .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 }}

故障排除

易变测试

  • 确保一致的时机(等待网络空闲)
  • 禁用动画
  • 模拟随机性
  • 使用固定日期/时间

大差异

  • 检查字体渲染差异
  • 验证图片加载
  • 检查动画时机
  • 审查抗锯齿差异

误报

  • 调整阈值容忍度
  • 遮罩动态区域
  • 使用相对比较
  • 仔细审查差异图像

示例

另见:e2e-testing-automation, accessibility-testing, test-automation-framework用于全面的UI测试。