端到端测试
本页提供 Playwright Page Object 完整示例(登录+购物车流程)、关键路径选择标准、5 种 Flaky test 修复代码、视觉回归截图配置,以及 CI 4-worker 分片策略。
要点与定位 + 关键路径选择标准
E2E 证明「装配正确」与关键用户旅程,不替代单元/集成测试。SKILL 中应写清:基址 URL、登录/鉴权方式、测试数据生命周期,以及失败时保留 trace 与截图的路径约定。
登录、支付等关键流可拆成可组合步骤与页面对象(或等价抽象),并说明预览环境与生产差异(Feature Flag、第三方沙箱)。
- 每个场景有明确 Given-When-Then,断言聚焦用户可感知结果。
- 并行跑 job 时使用独立账号或隔离数据集。
- 截图与 trace 在失败时自动保留,便于 Agent 辅助诊断。
关键路径选择标准(只测最高商业价值的 3-5 个场景):
选择 E2E 场景的决策树: 必须写 E2E 的场景(只要符合其中一条): ✓ 与收入直接相关:注册、登录、支付、下单 ✓ 监管/合规必须验证:实名认证、数据导出、账号注销 ✓ 多服务串联、单一集成测试无法覆盖的完整链路 不应该写 E2E 的场景: ✗ 单元/集成测试已覆盖的纯逻辑(如折扣计算) ✗ 偶尔使用的后台管理功能(风险低,维护成本高) ✗ 仅视觉差异(改用视觉回归测试代替) 优先级排序方法(用矩阵): 高商业价值 + 高技术风险 → 必须有 E2E(例:结账流程) 高商业价值 + 低技术风险 → 集成测试足够(例:个人信息修改) 低商业价值 + 高技术风险 → E2E 可选(例:数据导出) 低商业价值 + 低技术风险 → 不需要 E2E
Playwright Page Object 完整示例(登录 + 购物车流程):
// tests/e2e/pages/LoginPage.ts
import { Page, Locator } from '@playwright/test';
export class LoginPage {
private readonly emailInput: Locator;
private readonly passwordInput: Locator;
private readonly submitButton: Locator;
constructor(private page: Page) {
this.emailInput = page.getByLabel('电子邮件');
this.passwordInput = page.getByLabel('密码');
this.submitButton = page.getByRole('button', { name: '登录' });
}
async goto() {
await this.page.goto('/login');
}
async login(email: string, password: string) {
await this.emailInput.fill(email);
await this.passwordInput.fill(password);
await this.submitButton.click();
// 等待导航完成(不用 sleep,用路由变化确认)
await this.page.waitForURL('/dashboard');
}
}
// tests/e2e/pages/CartPage.ts
export class CartPage {
constructor(private page: Page) {}
async addItem(productName: string) {
await this.page.getByRole('button', { name: `加入购物车 ${productName}` }).click();
// 等待购物车图标数字更新,而不是 sleep
await this.page.getByTestId('cart-count').filter({ hasText: /[1-9]/ }).waitFor();
}
async checkout() {
await this.page.getByRole('link', { name: '查看购物车' }).click();
await this.page.waitForURL('/cart');
await this.page.getByRole('button', { name: '去结算' }).click();
await this.page.waitForURL('/checkout');
}
async getItemCount(): Promise<number> {
const text = await this.page.getByTestId('cart-count').textContent();
return parseInt(text || '0');
}
}
// tests/e2e/checkout.spec.ts
import { test, expect } from '@playwright/test';
import { LoginPage } from './pages/LoginPage';
import { CartPage } from './pages/CartPage';
test.describe('结账完整流程', () => {
test.beforeEach(async ({ page }) => {
// 通过 API 创建测试账号(不通过 UI 注册,更快且稳定)
await page.request.post('/api/test/create-user', {
data: { email: 'test@example.com', password: 'Test1234!' }
});
});
test('用户可以登录并完成下单', async ({ page }) => {
const loginPage = new LoginPage(page);
const cartPage = new CartPage(page);
// 1. 登录
await loginPage.goto();
await loginPage.login('test@example.com', 'Test1234!');
// 2. 添加商品
await page.goto('/products');
await cartPage.addItem('笔记本电脑');
expect(await cartPage.getItemCount()).toBe(1);
// 3. 结算
await cartPage.checkout();
await expect(page).toHaveURL('/checkout');
await expect(page.getByRole('heading', { name: '确认订单' })).toBeVisible();
});
});
选择器稳定性
优先可访问性查询(角色 + 可见名称)、稳定文案(经产品确认)、以及团队约定的 data-testid(或等价契约属性);避免依赖样式类名、DOM 深度、自动生成的 id 与脆弱的长 XPath。
在 SKILL 中禁止裸 sleep:改用自动等待(expect / assertion retry)、网络空闲、或框架提供的「动作后稳定」钩子;动画密集区域可对「稳定态」单独约定(如路由完成、骨架屏消失)。
Flaky 治理(5 种常见原因和修复代码)
重试(job 级或用例级)只应是最后手段;更应在 SKILL 中要求对失败做根因分类:竞态(断言早于 UI/网络就绪)、数据脏(并行共用账号或缓存)、外部依赖(第三方限流、时钟)、环境漂移(浏览器版本、区域 CDN)。
❌ Flaky 原因 1:硬编码 sleep 替代等待
// 错误写法(偶尔加载慢就失败)
await page.click('#submit');
await page.waitForTimeout(2000); // ← 固定等 2 秒
await expect(page.locator('.success')).toBeVisible();
// 修复写法(等待明确条件)
await page.click('#submit');
await expect(page.locator('.success')).toBeVisible({ timeout: 10_000 });
---
❌ Flaky 原因 2:并行测试共用同一账号
// 错误写法(两个并行测试抢同一用户数据)
const user = { email: 'fixed-test@example.com' };
// 修复写法(每个测试独立账号)
const uniqueEmail = `test-${Date.now()}-${Math.random().toString(36).slice(2)}@example.com`;
---
❌ Flaky 原因 3:断言位置比导航更早
// 错误写法
await page.click('[data-testid="order-btn"]');
await expect(page.locator('.order-id')).toBeVisible(); // ← 可能还没跳转
// 修复写法(先等 URL 变化,再断言)
await page.click('[data-testid="order-btn"]');
await page.waitForURL('/order-confirm/**');
await expect(page.locator('.order-id')).toBeVisible();
---
❌ Flaky 原因 4:动画未完成时就截图/断言
// 错误写法
await page.click('.menu-trigger');
await expect(page.locator('.dropdown-menu')).toHaveScreenshot(); // ← 动画中
// 修复写法(等待动画完成状态标记)
await page.click('.menu-trigger');
await page.locator('.dropdown-menu').waitFor({ state: 'visible' });
await page.locator('.dropdown-menu').evaluate(el =>
new Promise(resolve => {
el.addEventListener('animationend', resolve, { once: true });
})
);
await expect(page.locator('.dropdown-menu')).toHaveScreenshot();
---
❌ Flaky 原因 5:依赖系统时钟导致边界条件不稳定
// 错误写法(优惠券刚好今天过期,部分环境可能失败)
const coupon = { expiresAt: new Date().toISOString() };
// 修复写法(测试数据使用明确的过去/未来时间)
const pastDate = new Date('2020-01-01').toISOString();
const futureDate = new Date('2099-12-31').toISOString();
const expiredCoupon = { expiresAt: pastDate }; // 永远是已过期
const validCoupon = { expiresAt: futureDate }; // 永远是有效的
- 同一用例连续失败与「偶发失败」分开统计;偶发应进专项 issue 并钉死复现条件。
- 禁止「加长 sleep 糊弄过关」:改为等待明确条件或收紧测试数据前置条件。
- 对已知 flake 可暂时 quarantine(单独 job、非主干门禁),但必须带到期日与负责人。
CI 中的 E2E(4-worker 分片 + 视觉回归)
流水线应让反馈有序:先廉价信号、再昂贵 E2E;E2E job 需可复用构建产物或预览环境,并在失败时上传 artifacts。并行 shard 时每个分片使用独立数据切片或账号池,避免互相踩数据。
Playwright 视觉回归截图配置:
// playwright.config.ts
import { defineConfig } from '@playwright/test';
export default defineConfig({
use: {
// 截图只在失败时保存
screenshot: 'only-on-failure',
// 视频只在失败时保存
video: 'retain-on-failure',
// 追踪信息(用于调试 flaky test)
trace: 'on-first-retry',
},
// 视觉回归配置
expect: {
toHaveScreenshot: {
// 允许 0.1% 的像素差异(抗锯齿/字体渲染差异)
maxDiffPixelRatio: 0.001,
// 比较时忽略动态内容(时间戳等),使用 mask
},
},
// 只在 main 分支跑完整 E2E(PR 只跑冒烟测试)
projects: [
{
name: 'smoke',
testMatch: '**/*.smoke.spec.ts',
use: { ...devices['Desktop Chrome'] },
},
{
name: 'full-e2e',
testMatch: '**/*.spec.ts',
use: { ...devices['Desktop Chrome'] },
// 只在 main 分支/tag 时运行
},
],
});
// 视觉回归测试示例
test('checkout page visual regression', async ({ page }) => {
await page.goto('/checkout');
await page.waitForLoadState('networkidle');
// 遮罩动态内容(时间戳、倒计时等)
const screenshot = await page.screenshot({
mask: [page.locator('[data-testid="countdown-timer"]')]
});
expect(screenshot).toMatchSnapshot('checkout-page.png');
});
4-worker 并行 + 只在 main 分支跑完整套件:
name: E2E Tests
on:
push:
branches: [main] # 完整 E2E 只在 main
pull_request: # PR 只跑冒烟测试
jobs:
e2e-smoke:
if: github.event_name == 'pull_request'
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with: { node-version: '20', cache: 'npm' }
- run: npm ci
- run: npx playwright install --with-deps chromium
- run: npx playwright test --project=smoke
- uses: actions/upload-artifact@v4
if: failure()
with: { name: smoke-test-results, path: playwright-report/ }
e2e-full:
if: github.ref == 'refs/heads/main'
runs-on: ubuntu-latest
strategy:
matrix:
shardIndex: [1, 2, 3, 4] # 4 个 worker 并行
shardTotal: [4]
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with: { node-version: '20', cache: 'npm' }
- run: npm ci
- run: npx playwright install --with-deps
- run: npx playwright test --shard=${{ matrix.shardIndex }}/${{ matrix.shardTotal }}
env:
BASE_URL: ${{ vars.STAGING_URL }}
- uses: actions/upload-artifact@v4
if: always()
with:
name: e2e-report-shard-${{ matrix.shardIndex }}
path: playwright-report/
[ push / 打开 PR ]
│
▼
[ lint · typecheck · 单元测试 ] ← 快速失败
│
▼
[ 构建 / 预览环境 / 镜像 ]
│
▼
[ E2E:串行关键路径 或 shard 并行 + 数据隔离 ]
│
┌────────┴────────┐
▼ ▼
[ 通过:合并门禁 ] [ 失败:trace / 截图 / 视频 ]
│ │
│ ▼
│ [ 分类:真 bug / flake / 环境 ]
│ │
└──────────────────────┘
│
▼
[ 回归或 quarantine 跟进 ]
选择器小贴士生成器
填写目标元素与页面上下文,生成本页定制的选择器稳定性要点,便于贴进 SKILL 或评审评论。内容仅保存在本浏览器(localStorage),不上传。
---
name: e2e-testing-cn
description: Playwright Page Object、Flaky 修复、视觉回归与 CI 分片策略
---
# 关键路径选择(只测 3-5 个最高商业价值场景)
必须测: 注册、登录、支付、下单(直接影响收入)
不要测: 纯逻辑计算(改用集成测试)、后台管理低频功能
# Page Object 结构
tests/e2e/pages/LoginPage.ts → goto() + login(email, pwd)
tests/e2e/pages/CartPage.ts → addItem(name) + checkout() + getItemCount()
tests/e2e/specs/checkout.spec.ts → 组合 Page Objects,Given-When-Then
# 选择器优先级(稳定性从高到低)
1. getByRole('button', { name: '提交' }) ← 最稳定
2. getByLabel('电子邮件')
3. getByTestId('submit-btn') ← 需与前端约定 data-testid
4. page.locator('.btn-primary') ← 避免(样式改变即断)
# 5 种 Flaky 修复
竞态: 用 waitForURL / waitFor({ state: 'visible' }) 替代 sleep
并发: 每测试独立账号(email = `test-${Date.now()}@example.com`)
导航: 先 waitForURL 再断言元素
动画: waitFor animationend 事件后再截图
时钟: 测试数据用固定过去/未来时间(2020-01-01 / 2099-12-31)
# 视觉回归配置
maxDiffPixelRatio: 0.001 # 允许 0.1% 像素差异
mask: [page.locator('[data-testid="dynamic-content"]')] # 遮罩动态区域
screenshot: 'only-on-failure'
trace: 'on-first-retry'
# CI 分片策略
PR: 只跑 smoke tests(project=smoke, testMatch=**/*.smoke.spec.ts)
main: 4 个 worker 并行(--shard=1/4, 2/4, 3/4, 4/4)
失败时: upload-artifact playwright-report/ 目录