Playwright E2E 测试深度实战:从用例编写到 CI 集成

前端工程

为什么是 Playwright,而不是 Cypress 或 Selenium

我在三个项目里分别用了 Selenium、Cypress 和 Playwright 做 E2E 测试。Selenium 的体验最痛苦——WebDriver 协议慢、等待逻辑全靠 sleep、浏览器版本匹配像拆炸弹。Cypress 在中型项目跑了大半年,直到有一天被测页面开了三个 window.open,Cypress 直接罢工——它不支持多标签页。

Playwright 解决了这些痛点。它由同一批从 Google 出来的 Puppeteer 团队开发,原生支持 Chromium、Firefox 和 WebKit,API 设计的一致性让人舒服。更关键的是它有自动等待机制——点击按钮前自动等元素可见且可交互,不需要手写 waitForSelector

这篇文章不是 API 翻译。我会聚焦在实际项目中最常用的模式,以及团队从 0 到 50 个测试用例的经验总结。


环境搭建与项目结构

npm init playwright@latest

初始化过程会询问你几个问题,选择 TypeScript + 默认目录结构即可。生成的项目结构:

e2e/
├── tests/
│   ├── example.spec.ts
│   ├── login.spec.ts
│   └── dashboard.spec.ts
├── fixtures/
│   └── auth.setup.ts       # 登录态复用
├── pages/                   # Page Object 模式
│   ├── LoginPage.ts
│   └── DashboardPage.ts
├── utils/
│   └── helpers.ts
└── playwright.config.ts

playwright.config.ts 核心配置

import { defineConfig, devices } from '@playwright/test';

export default defineConfig({
  testDir: './e2e/tests',
  fullyParallel: true,
  forbidOnly: !!process.env.CI,
  retries: process.env.CI ? 2 : 0,
  workers: process.env.CI ? 3 : undefined,
  reporter: [
    ['html', { outputFolder: 'playwright-report' }],
    ['json', { outputFile: 'test-results.json' }],
  ],
  use: {
    baseURL: 'http://localhost:3000',
    trace: 'on-first-retry',    // 失败重试时记录 trace
    screenshot: 'only-on-failure',
    video: 'retain-on-failure',
  },
  projects: [
    {
      name: 'chromium',
      use: { ...devices['Desktop Chrome'] },
    },
    {
      name: 'firefox',
      use: { ...devices['Desktop Firefox'] },
    },
    {
      name: 'webkit',
      use: { ...devices['Desktop Safari'] },
    },
    // 移动端
    {
      name: 'Mobile Chrome',
      use: { ...devices['Pixel 5'] },
    },
  ],
  webServer: {
    command: 'npm run dev',
    url: 'http://localhost:3000',
    reuseExistingServer: !process.env.CI,
  },
});

重点解读几个配置项:

  • fullyParallel: true —— 所有测试文件和用例完全并行,CI 中跑 50 个用例只需 2-3 分钟
  • trace: 'on-first-retry' —— 失败时自动记录 trace,事后回放可以像看录像一样定位问题
  • webServer —— 自动启动开发服务器,不用手动 npm run dev

定位器策略:稳定性的第一道防线

不稳定的定位器是 E2E 测试的头号杀手。DOM 结构一变,测试全红。Playwright 提供了多层次的定位器方案,按推荐优先级排列:

优先级 定位器 示例 稳定性
最高 getByRole page.getByRole('button', { name: '提交' }) ✅ 极稳定
getByLabel page.getByLabel('用户名') ✅ 稳定
getByPlaceholder page.getByPlaceholder('请输入邮箱') ✅ 稳定
getByText page.getByText('订单详情') ✅ 稳定
getByTestId page.getByTestId('submit-btn') ✅ 稳定
locator (CSS) page.locator('.login-form .submit') ⚠️ 易变
locator (XPath) page.locator('//div[@class="btn"]') ❌ 极易变

实战对比

// ❌ 差:CSS 类名改了测试就挂
await page.locator('#app > div > form > button.btn-primary').click();

// ⚠️ 凑合:testId 稳定但需要改源码加 data-testid
await page.getByTestId('login-submit').click();

// ✅ 好:基于无障碍语义,与 UI 框架无关
await page.getByRole('button', { name: '登录' }).click();

// ✅ 好:基于可访问标签
await page.getByLabel('邮箱地址').fill('test@example.com');
await page.getByPlaceholder('请输入密码').fill('Secret123');

getByRole 的匹配基于 WAI-ARIA 语义。只要你的 HTML 使用语义化标签或正确的 role 属性,它就能找到。这是最稳定的方案,因为即使用 Tailwind 改了所有 class,role 不会变。

过滤与链式定位

// 找到列表中的特定项
const item = page.getByRole('listitem').filter({ hasText: '订单 #2024' });

// 找到表格中包含特定文本的行
const row = page.getByRole('row').filter({ has: page.getByText('已完成') });

// 链式定位:在卡片内部查找按钮
const card = page.getByTestId('user-card');
await card.getByRole('button', { name: '编辑' }).click();

// 精确文本 vs 模糊匹配
page.getByText('删除', { exact: true });    // 只匹配"删除"
page.getByText('删除');                      // 匹配包含"删除"的元素

断言策略:别什么都 assert

E2E 测试里最常犯的错误是过度断言。每个步骤都 expect 一遍,测试可读性差,维护成本高。

推荐策略:只断言用户能感知的

test('用户登录成功后跳转到仪表盘', async ({ page }) => {
  // 1. 操作:直接操作,不每次断言中间状态
  await page.goto('/login');
  await page.getByLabel('邮箱').fill('admin@example.com');
  await page.getByLabel('密码').fill('password123');

  // 2. 关键断言:用户能看到的最终结果
  await page.getByRole('button', { name: '登录' }).click();
  await expect(page).toHaveURL('/dashboard');
  await expect(page.getByText('欢迎回来,Admin')).toBeVisible();

  // 3. 反向断言:确认不该出现的元素不存在
  await expect(page.getByText('登录失败')).not.toBeVisible();
});

常用断言速查

// 页面级别的断言(自动等待)
await expect(page).toHaveTitle(/仪表盘/);
await expect(page).toHaveURL(/\/dashboard$/);

// 元素可见性(自动等待元素出现)
await expect(page.getByText('保存成功')).toBeVisible();

// 计数
await expect(page.getByRole('listitem')).toHaveCount(5);

// 输入值
await expect(page.getByLabel('邮箱')).toHaveValue('admin@example.com');

// 文本内容
await expect(page.getByTestId('order-total')).toContainText('¥299');

// CSS 属性
await expect(page.getByRole('button', { name: '提交' })).toBeDisabled();
await expect(page.getByTestId('error-banner')).toHaveClass(/error/);

// 截图对比(视觉回归)
await expect(page).toHaveScreenshot('homepage.png');

网络拦截与 Mock:不用改后端也能测试

E2E 测试最大的痛点之一是依赖后端数据。后端挂了测试全红,这不是前端的问题。Playwright 的 route API 可以在网络层拦截请求:

基础拦截

test('展示用户列表——Mock 后端数据', async ({ page }) => {
  // 拦截 API 请求,返回 Mock 数据
  await page.route('**/api/users**', async (route) => {
    await route.fulfill({
      status: 200,
      contentType: 'application/json',
      body: JSON.stringify({
        data: [
          { id: 1, name: '张三', email: 'zhangsan@example.com' },
          { id: 2, name: '李四', email: 'lisi@example.com' },
        ],
        total: 2,
      }),
    });
  });

  await page.goto('/users');
  await expect(page.getByText('张三')).toBeVisible();
  await expect(page.getByText('李四')).toBeVisible();
});

条件拦截——精确到请求参数

await page.route('**/api/orders**', async (route, request) => {
  const url = new URL(request.url());
  const status = url.searchParams.get('status');

  if (status === 'pending') {
    await route.fulfill({
      body: JSON.stringify({ data: pendingOrders }),
    });
  } else {
    // 不匹配的请求放行到真实服务器
    await route.fallback();
  }
});

修改响应——测试边界情况

// 模拟超长用户名
await page.route('**/api/profile', async (route) => {
  const response = await route.fetch();   // 先获取真实响应
  const json = await response.json();
  json.name = '这是一个非常非常非常非常非常非常非常长的用户名';
  await route.fulfill({ json });
});

模拟网络异常

// 模拟 500 错误
await page.route('**/api/submit', async (route) => {
  await route.fulfill({
    status: 500,
    body: JSON.stringify({ error: 'Internal Server Error' }),
  });
});

await page.getByRole('button', { name: '提交' }).click();
await expect(page.getByText('服务器错误,请稍后重试')).toBeVisible();

// 模拟网络超时
await page.route('**/api/slow-endpoint', async (route) => {
  await route.abort('timedout');
});

身份认证复用:每次测试都登录太慢了

如果每个测试都要先走一遍登录流程,50 个用例光登录就要花 2-3 分钟。Playwright 的 storageState 可以复用登录态:

全局登录设置

// playwright.config.ts
export default defineConfig({
  projects: [
    {
      name: 'chromium',
      use: {
        ...devices['Desktop Chrome'],
        // 指定 storageState 文件
        storageState: 'e2e/.auth/user.json',
      },
    },
    // 不需要登录态的测试项目
    {
      name: 'chromium-no-auth',
      use: { ...devices['Desktop Chrome'] },
      testMatch: /.*\.no-auth\.spec\.ts/,
    },
    {
      name: 'setup',
      testMatch: /.*\.setup\.ts/,
    },
  ],
});

登录设置脚本

// e2e/tests/auth.setup.ts
import { test as setup } from '@playwright/test';
import path from 'path';

const authFile = path.join(__dirname, '../.auth/user.json');

setup('登录并保存状态', async ({ page }) => {
  await page.goto('/login');
  await page.getByLabel('邮箱').fill('admin@example.com');
  await page.getByLabel('密码').fill('password123');
  await page.getByRole('button', { name: '登录' }).click();

  // 等待登录成功
  await expect(page).toHaveURL('/dashboard');

  // 保存登录状态(cookie + localStorage)
  await page.context().storageState({ path: authFile });
});

运行后生成 user.json,后续测试自动用这个文件恢复登录态,秒进。


Page Object 模式——别让你的测试变成意大利面

// e2e/pages/LoginPage.ts
import { type Page, type Locator } from '@playwright/test';

export class LoginPage {
  readonly emailInput: Locator;
  readonly passwordInput: Locator;
  readonly submitButton: Locator;
  readonly errorMessage: Locator;

  constructor(readonly page: Page) {
    this.emailInput = page.getByLabel('邮箱');
    this.passwordInput = page.getByLabel('密码');
    this.submitButton = page.getByRole('button', { name: '登录' });
    this.errorMessage = page.getByTestId('login-error');
  }

  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();
  }

  async expectError(message: string) {
    await expect(this.errorMessage).toContainText(message);
  }
}

测试中调用:

test('错误密码显示错误提示', async ({ page }) => {
  const loginPage = new LoginPage(page);
  await loginPage.goto();
  await loginPage.login('admin@example.com', 'wrong-password');
  await loginPage.expectError('邮箱或密码错误');
});

视觉回归测试

test('首页截图对比', async ({ page }) => {
  await page.goto('/');

  // 全页截图对比(首次运行生成基准,后续对比)
  await expect(page).toHaveScreenshot('homepage.png', {
    fullPage: true,
    maxDiffPixelRatio: 0.01,  // 容忍 1% 的像素差异
  });
});

test('按钮组件暗色模式截图', async ({ page }) => {
  await page.emulateMedia({ colorScheme: 'dark' });
  await page.goto('/components/button');

  // 元素级截图
  await expect(page.getByTestId('primary-btn')).toHaveScreenshot(
    'button-primary-dark.png'
  );
});

截图文件存储在 e2e/tests/__screenshots__/ 中。首次运行生成基准图,后续对比差异。CI 中建议用 Docker 固定环境保证截图一致。


CI/CD 集成

GitHub Actions 示例

name: E2E Tests
on:
  pull_request:
    branches: [main]

jobs:
  e2e:
    timeout-minutes: 15
    runs-on: ubuntu-latest
    strategy:
      matrix:
        shard: [1, 2, 3]   # 分片并行

    steps:
      - uses: actions/checkout@v4

      - uses: actions/setup-node@v4
        with:
          node-version: 20

      - name: Install dependencies
        run: npm ci

      - name: Install Playwright browsers
        run: npx playwright install --with-deps chromium

      - name: Run E2E tests
        run: npx playwright test --shard=${{ matrix.shard }}/3

      - name: Upload test report
        if: always()
        uses: actions/upload-artifact@v4
        with:
          name: playwright-report-${{ matrix.shard }}
          path: playwright-report/
          retention-days: 7

调试指南

Trace Viewer——最强大的调试工具

# 运行测试并强制记录 trace
npx playwright test --trace on

# 打开 trace viewer
npx playwright show-trace test-results/example-LoginPage/trace.zip

Trace 能回放每一步操作、查看网络请求、控制台日志、DOM 快照——相当于测试过程的完整录像。

VS Code 插件

安装 Playwright Test 插件后,可以在编辑器中直接运行和调试单个用例。断点、单步执行、变量查看——和调试业务代码一样。

常用调试命令

# 只运行失败的用例
npx playwright test --last-failed

# 指定文件 + 行号
npx playwright test tests/login.spec.ts:42

# headed 模式(看到浏览器窗口)
npx playwright test --headed

# 慢速执行,看清楚每一步
npx playwright test --headed --debug

常见问题

Q:Playwright 和 Cypress 怎么选?

A:新项目直接选 Playwright。Cypress 的优势是社区成熟度和 Dashboard 服务,但在技术层面——多标签页支持、多浏览器、自动等待、并行执行——Playwright 都更优。如果团队已经深度使用 Cypress 且迁移成本高,继续用也可以。

Q:E2E 测试应该覆盖多少?

A:推荐"测试金字塔"比例——单元测试 70%,集成测试 20%,E2E 测试 10%。E2E 只覆盖核心用户流程:注册/登录、核心业务闭环、支付流程。边缘情况和异常路径交给单元和集成测试。

Q:接口改了前端测试全挂怎么办?

A:核心业务流用真实接口(确保端到端正确),非核心流程用 page.route() Mock 数据。把 Mock 数据放在 fixtures 目录统一管理,接口变更时改一处即可。

Q:截图测试在 CI 里经常因为字体差异失败?

A:CI 的 Docker 镜像需要安装中文字体。可以在 Dockerfile 里加 RUN apt-get install -y fonts-noto-cjk,或者截图元素级别而非整页,缩小差异范围。


相关工具

本站提供浏览器本地工具,免注册即可试用 →

#Playwright#E2E测试#自动化测试#前端#教程