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,或者截图元素级别而非整页,缩小差异范围。
相关工具
- JSON 格式化 — 格式化 API Mock 数据
- 文本差异对比 — 对比测试输出与期望值
- Base64 编码解码 — 处理截图与文件上传测试
本站提供浏览器本地工具,免注册即可试用 →