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