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测试。