Playwright 视觉测试 #

视觉测试概述 #

视觉回归测试通过对比截图来检测 UI 的意外变化,是 E2E 测试的重要补充。

视觉测试类型 #

text
┌─────────────────────────────────────────────────────────────┐
│                    视觉测试类型                               │
├─────────────────────────────────────────────────────────────┤
│                                                             │
│  📸 全页快照    - 整个页面的视觉对比                          │
│  🎯 元素快照    - 特定元素的视觉对比                          │
│  📱 响应式测试  - 不同设备尺寸的视觉对比                      │
│  🌙 主题测试    - 不同主题的视觉对比                          │
│                                                             │
└─────────────────────────────────────────────────────────────┘

基本快照测试 #

页面快照 #

typescript
import { test, expect } from '@playwright/test';

test('页面快照测试', async ({ page }) => {
  await page.goto('/');
  
  // 基本快照
  await expect(page).toHaveScreenshot('homepage.png');
  
  // 自动命名
  await expect(page).toHaveScreenshot();
  
  // 全页快照
  await expect(page).toHaveScreenshot('fullpage.png', {
    fullPage: true,
  });
});

元素快照 #

typescript
test('元素快照测试', async ({ page }) => {
  await page.goto('/');
  
  // 特定元素快照
  const card = page.locator('.product-card');
  await expect(card).toHaveScreenshot('card.png');
  
  // 列表项快照
  const item = page.locator('.list-item').first();
  await expect(item).toHaveScreenshot('list-item.png');
  
  // 表单快照
  const form = page.locator('.login-form');
  await expect(form).toHaveScreenshot('login-form.png');
});

快照配置 #

全局配置 #

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

export default defineConfig({
  expect: {
    toHaveScreenshot: {
      // 最大差异像素
      maxDiffPixels: 100,
      
      // 最大差异比例
      maxDiffPixelRatio: 0.1,
      
      // 对比阈值
      threshold: 0.2,
      
      // 动画处理
      animations: 'disabled',
      
      // 插图
      caret: 'hide',
      
      // 缩放
      scale: 'css',
    },
  },
});

单个快照配置 #

typescript
test('自定义快照配置', async ({ page }) => {
  await page.goto('/');
  
  await expect(page).toHaveScreenshot('custom.png', {
    // 差异容忍
    maxDiffPixels: 100,
    maxDiffPixelRatio: 0.1,
    threshold: 0.2,
    
    // 屏蔽动态内容
    mask: [
      page.locator('.timestamp'),
      page.locator('.random-content'),
    ],
    maskColor: '#ff0000',
    
    // 裁剪区域
    clip: { x: 0, y: 0, width: 800, height: 600 },
    
    // 动画
    animations: 'disabled',
    
    // 插图
    caret: 'hide',
    
    // 全页
    fullPage: true,
    
    // 超时
    timeout: 10000,
  });
});

更新快照 #

更新所有快照 #

bash
# 更新所有快照
npx playwright test --update-snapshots

# 简写
npx playwright test -u

更新特定快照 #

bash
# 更新特定测试的快照
npx playwright test login.spec.ts -u

# 更新匹配的快照
npx playwright test -g "homepage" -u

交互式更新 #

typescript
test('交互式更新', async ({ page }) => {
  await page.goto('/');
  
  // 失败时可以选择更新
  await expect(page).toHaveScreenshot('homepage.png');
});

处理动态内容 #

屏蔽动态区域 #

typescript
test('屏蔽动态内容', async ({ page }) => {
  await page.goto('/');
  
  await expect(page).toHaveScreenshot('masked.png', {
    // 屏蔽时间戳
    mask: [
      page.locator('.timestamp'),
      page.locator('.date'),
      page.locator('.random-ad'),
    ],
    maskColor: '#ff0000',
  });
});

等待动态内容稳定 #

typescript
test('等待稳定', async ({ page }) => {
  await page.goto('/');
  
  // 等待动画完成
  await page.waitForLoadState('networkidle');
  
  // 等待特定元素稳定
  await expect(page.locator('.loading')).not.toBeVisible();
  
  await expect(page).toHaveScreenshot('stable.png', {
    animations: 'disabled',
  });
});

使用固定数据 #

typescript
test('使用固定数据', async ({ page }) => {
  // Mock API 返回固定数据
  await page.route('**/api/products', route => {
    route.fulfill({
      status: 200,
      body: JSON.stringify([
        { id: 1, name: 'Product 1', price: 100 },
        { id: 2, name: 'Product 2', price: 200 },
      ]),
    });
  });
  
  await page.goto('/products');
  await expect(page).toHaveScreenshot('products.png');
});

响应式视觉测试 #

多设备测试 #

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

export default defineConfig({
  projects: [
    {
      name: 'Desktop',
      use: { ...devices['Desktop Chrome'] },
    },
    {
      name: 'Tablet',
      use: { ...devices['iPad Pro'] },
    },
    {
      name: 'Mobile',
      use: { ...devices['iPhone 12'] },
    },
  ],
});

// 测试文件
test('响应式快照', async ({ page }) => {
  await page.goto('/');
  await expect(page).toHaveScreenshot('responsive.png');
});

多视口测试 #

typescript
test.describe('多视口测试', () => {
  const viewports = [
    { name: 'mobile', width: 375, height: 667 },
    { name: 'tablet', width: 768, height: 1024 },
    { name: 'desktop', width: 1920, height: 1080 },
  ];
  
  for (const viewport of viewports) {
    test(`${viewport.name} 视口`, async ({ page }) => {
      await page.setViewportSize(viewport);
      await page.goto('/');
      await expect(page).toHaveScreenshot(`${viewport.name}.png`);
    });
  }
});

主题测试 #

亮色/暗色主题 #

typescript
test.describe('主题测试', () => {
  test('亮色主题', async ({ page }) => {
    await page.emulateMedia({ colorScheme: 'light' });
    await page.goto('/');
    await expect(page).toHaveScreenshot('light-theme.png');
  });
  
  test('暗色主题', async ({ page }) => {
    await page.emulateMedia({ colorScheme: 'dark' });
    await page.goto('/');
    await expect(page).toHaveScreenshot('dark-theme.png');
  });
});

自定义主题 #

typescript
test('自定义主题', async ({ page }) => {
  // 设置自定义主题
  await page.addStyleTag({
    content: `
      :root {
        --primary-color: #ff0000;
        --background-color: #f0f0f0;
      }
    `,
  });
  
  await page.goto('/');
  await expect(page).toHaveScreenshot('custom-theme.png');
});

快照组织 #

快照目录结构 #

text
tests/
├── homepage.spec.ts
└── __snapshots__/
    └── homepage.spec.ts/
        ├── homepage-1.png
        ├── homepage-2.png
        └── ...

自定义快照路径 #

typescript
// playwright.config.ts
export default defineConfig({
  snapshotPathTemplate: '{testDir}/__snapshots__/{testFilePath}/{arg}{ext}',
});

命名约定 #

typescript
test.describe('用户资料页面', () => {
  test('默认状态', async ({ page }) => {
    await page.goto('/profile');
    await expect(page).toHaveScreenshot('profile-default.png');
  });
  
  test('编辑模式', async ({ page }) => {
    await page.goto('/profile');
    await page.click('button:has-text("Edit")');
    await expect(page).toHaveScreenshot('profile-edit.png');
  });
  
  test('保存成功', async ({ page }) => {
    await page.goto('/profile');
    // ... 编辑操作
    await page.click('button:has-text("Save")');
    await expect(page).toHaveScreenshot('profile-saved.png');
  });
});

视觉测试最佳实践 #

1. 保持快照简洁 #

typescript
// ✅ 推荐 - 只测试关键元素
test('关键元素快照', async ({ page }) => {
  await page.goto('/');
  const hero = page.locator('.hero-section');
  await expect(hero).toHaveScreenshot('hero.png');
});

// ❌ 不推荐 - 测试整个页面(包含太多动态内容)
test('全页快照', async ({ page }) => {
  await page.goto('/');
  await expect(page).toHaveScreenshot('full.png');
});

2. 屏蔽动态内容 #

typescript
// ✅ 推荐
await expect(page).toHaveScreenshot('page.png', {
  mask: [
    page.locator('.timestamp'),
    page.locator('.random-content'),
  ],
});

3. 使用固定测试数据 #

typescript
// ✅ 推荐 - 使用 Mock 数据
test.beforeEach(async ({ page }) => {
  await page.route('**/api/**', route => {
    route.fulfill({
      body: JSON.stringify(fixedTestData),
    });
  });
});

4. 定期审查快照 #

typescript
// 添加注释说明快照预期
test('用户卡片快照 - 应显示头像、名称和简介', async ({ page }) => {
  await page.goto('/users/1');
  const card = page.locator('.user-card');
  await expect(card).toHaveScreenshot('user-card.png');
});

5. 合理设置差异容忍度 #

typescript
// 对于精确要求高的组件
await expect(page.locator('.logo')).toHaveScreenshot('logo.png', {
  maxDiffPixels: 0, // 不允许任何差异
});

// 对于可能有细微差异的组件
await expect(page.locator('.dynamic-content')).toHaveScreenshot('content.png', {
  maxDiffPixelRatio: 0.05, // 允许 5% 差异
});

视觉测试工作流 #

开发阶段 #

bash
# 1. 编写测试
# 2. 运行测试,生成初始快照
npx playwright test

# 3. 检查快照是否正确
# 4. 提交快照
git add tests/__snapshots__
git commit -m "Add visual snapshots"

CI 阶段 #

yaml
# .github/workflows/test.yml
- name: Run visual tests
  run: npx playwright test
  
- name: Upload diff
  if: failure()
  uses: actions/upload-artifact@v3
  with:
    name: snapshot-diff
    path: test-results

更新快照 #

bash
# 1. 确认 UI 变更是预期的
# 2. 更新快照
npx playwright test -u

# 3. 审查新快照
# 4. 提交更新
git add tests/__snapshots__
git commit -m "Update visual snapshots"

下一步 #

现在你已经掌握了视觉测试,接下来学习 API 测试 了解如何测试 API!

最后更新:2026-03-28