端到端测试

本页提供 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)、网络空闲、或框架提供的「动作后稳定」钩子;动画密集区域可对「稳定态」单独约定(如路由完成、骨架屏消失)。

清单:能否用角色+名称唯一命中?若列表重复,是否先限定父级(landmark / 表单)再定位?动态列表是否应用「文本或行内 testid」而非序号?

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/ 目录

返回技能库 更多技能入口