为什么选择 Playwright
Playwright 由 Microsoft 开发(核心团队来自 Puppeteer),是 2024-2025 年增长最快的 E2E 测试框架。相比 Cypress,Playwright 在以下维度有明显优势:
| 维度 | Playwright | Cypress |
|---|---|---|
| 浏览器支持 | Chromium + Firefox + WebKit | Chromium 为主 |
| 多标签页/多窗口 | ✅ 原生支持 | ❌ 不支持 |
| iframe | ✅ 原生支持 | 部分支持 |
| 并行执行 | ✅ Worker 级并行 | 需要 Cypress Cloud |
| 执行模型 | 进程外控制(CDP/WebSocket) | 进程内注入 |
| 语言支持 | TS/JS/Python/Java/C# | JS/TS only |
| 免费 CI 集成 | ✅ 完全免费 | Dashboard 需付费 |
一、Playwright 架构
1.1 核心组件
Test Runner (playwright/test)
|
├── Browser Server (Chromium / Firefox / WebKit)
│ |
│ ├── Browser Context (隔离的浏览器会话)
│ │ |
│ │ ├── Page(标签页)
│ │ │ ├── Frame(iframe)
│ │ │ └── Locator(元素定位器)
│ │ │
│ │ └── Page(可多个标签页)
│ │
│ └── Browser Context(可多个独立上下文)
│
└── Reporter(测试报告生成)
1.2 Browser Context 隔离
每个测试默认获得一个独立的 Browser Context,相当于隐身窗口:
- 独立的 Cookie、LocalStorage、SessionStorage
- 独立的 Service Workers
- 独立的权限设置
- 无需在测试间手动清理状态
// 每个测试自动获得隔离的 context 和 page
test('test A', async ({ page }) => {
// page A 的状态完全独立
});
test('test B', async ({ page }) => {
// page B 与 A 互不影响
});
二、配置与项目结构
2.1 playwright.config.ts
import { defineConfig, devices } from '@playwright/test';
export default defineConfig({
testDir: './e2e',
fullyParallel: true,
forbidOnly: !!process.env.CI, // CI 中禁止 .only
retries: process.env.CI ? 2 : 0, // CI 中自动重试
workers: process.env.CI ? 1 : undefined,
reporter: [
['html', { open: 'never' }],
['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 7'] },
},
{
name: 'mobile-safari',
use: { ...devices['iPhone 14'] },
},
],
webServer: {
command: 'npm run dev',
url: 'http://localhost:3000',
reuseExistingServer: !process.env.CI,
},
});
2.2 项目结构
e2e/
├── fixtures/ ← 自定义 Fixture
│ └── auth.ts
├── pages/ ← Page Object Models
│ ├── login.page.ts
│ └── dashboard.page.ts
├── auth.spec.ts ← 按功能模块组织
├── checkout.spec.ts
└── global-setup.ts ← 全局 setup(如认证)
三、Locator — 元素定位
3.1 推荐的 Locator 优先级
// 1. Role-based(最推荐 — 与 Testing Library 一致)
page.getByRole('button', { name: 'Submit' });
page.getByRole('link', { name: 'Home' });
page.getByRole('heading', { level: 1 });
// 2. Label-based(表单元素)
page.getByLabel('Email');
page.getByPlaceholder('Enter your email');
// 3. Text-based
page.getByText('Welcome back');
page.getByText(/welcome/i); // 正则
// 4. Test ID(最后手段)
page.getByTestId('submit-button');
// 5. CSS / XPath(不推荐,但有时需要)
page.locator('.card >> nth=0');
page.locator('xpath=//div[@class="container"]');
3.2 Locator 链式操作
// 过滤
page.getByRole('listitem').filter({ hasText: 'Product A' });
page.getByRole('listitem').filter({ has: page.getByRole('button', { name: 'Buy' }) });
// 嵌套定位
const card = page.locator('.product-card').filter({ hasText: 'iPhone' });
await card.getByRole('button', { name: 'Add to Cart' }).click();
// 第 N 个元素
page.getByRole('listitem').nth(0);
page.getByRole('listitem').first();
page.getByRole('listitem').last();
3.3 Auto-waiting 机制
Playwright 的 Locator 内置自动等待,无需手动 waitForSelector:
// Playwright 自动等待按钮变为可见、可用后才点击
await page.getByRole('button', { name: 'Submit' }).click();
// 等待链:
// 1. 等待元素 attached 到 DOM
// 2. 等待元素 visible
// 3. 等待元素 stable(不在动画中)
// 4. 等待元素 enabled(非 disabled)
// 5. 等待元素接收事件(没有被其他元素遮挡)
// 6. 执行点击
四、Assertions — Web-First 断言
4.1 自动重试的断言
import { expect } from '@playwright/test';
// 这些断言会自动重试直到超时(默认 5s)
await expect(page.getByText('Success')).toBeVisible();
await expect(page.getByRole('button')).toBeEnabled();
await expect(page.getByRole('textbox')).toHaveValue('hello');
await expect(page.getByRole('checkbox')).toBeChecked();
await expect(page).toHaveURL('/dashboard');
await expect(page).toHaveTitle(/Dashboard/);
// 否定断言
await expect(page.getByText('Loading')).not.toBeVisible();
await expect(page.getByText('Error')).toBeHidden();
4.2 常用断言清单
// 元素状态
await expect(locator).toBeVisible();
await expect(locator).toBeHidden();
await expect(locator).toBeEnabled();
await expect(locator).toBeDisabled();
await expect(locator).toBeChecked();
await expect(locator).toBeFocused();
await expect(locator).toBeEditable();
// 内容
await expect(locator).toHaveText('exact text');
await expect(locator).toContainText('partial');
await expect(locator).toHaveValue('input value');
await expect(locator).toHaveAttribute('href', '/home');
await expect(locator).toHaveClass(/active/);
await expect(locator).toHaveCSS('color', 'rgb(0, 0, 0)');
await expect(locator).toHaveCount(5);
// 页面级
await expect(page).toHaveURL('/dashboard');
await expect(page).toHaveTitle('Dashboard');
await expect(page).toHaveScreenshot('dashboard.png');
五、Network Interception
5.1 拦截与 Mock API
test('should display mocked data', async ({ page }) => {
// 拦截 API 请求并返回 Mock 数据
await page.route('/api/users', (route) => {
route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify([
{ id: 1, name: 'Alice' },
{ id: 2, name: 'Bob' },
]),
});
});
await page.goto('/users');
await expect(page.getByText('Alice')).toBeVisible();
await expect(page.getByText('Bob')).toBeVisible();
});
5.2 等待网络请求
test('should submit form and wait for API response', async ({ page }) => {
await page.goto('/contact');
// 设置请求拦截(在操作之前)
const responsePromise = page.waitForResponse('/api/contact');
// 填写并提交表单
await page.getByLabel('Message').fill('Hello');
await page.getByRole('button', { name: 'Send' }).click();
// 等待 API 响应
const response = await responsePromise;
expect(response.status()).toBe(200);
await expect(page.getByText('Message sent!')).toBeVisible();
});
5.3 修改请求/响应
// 修改请求头
await page.route('/api/**', (route) => {
route.continue({
headers: {
...route.request().headers(),
'X-Custom-Header': 'test-value',
},
});
});
// 修改响应
await page.route('/api/config', async (route) => {
const response = await route.fetch();
const json = await response.json();
json.featureFlags.newUI = true; // 强制开启 feature flag
route.fulfill({ response, json });
});
六、Test Fixtures — 测试装置
6.1 内置 Fixtures
test('example', async ({
page, // 隔离的页面
context, // Browser Context
browser, // Browser 实例
request, // API 请求上下文(无 UI)
browserName, // 'chromium' | 'firefox' | 'webkit'
}) => {
// ...
});
6.2 自定义 Fixtures
// e2e/fixtures/auth.ts
import { test as base, type Page } from '@playwright/test';
type AuthFixtures = {
authenticatedPage: Page;
adminPage: Page;
};
export const test = base.extend<AuthFixtures>({
authenticatedPage: async ({ page }, use) => {
// Setup: 登录
await page.goto('/login');
await page.getByLabel('Email').fill('[email protected]');
await page.getByLabel('Password').fill('password');
await page.getByRole('button', { name: 'Login' }).click();
await page.waitForURL('/dashboard');
// 提供给测试使用
await use(page);
// Teardown: 登出
await page.goto('/logout');
},
adminPage: async ({ browser }, use) => {
const context = await browser.newContext({
storageState: 'e2e/.auth/admin.json', // 预存的认证状态
});
const page = await context.newPage();
await use(page);
await context.close();
},
});
export { expect } from '@playwright/test';
6.3 Global Setup — 认证状态复用
// e2e/global-setup.ts
import { chromium, type FullConfig } from '@playwright/test';
export default async function globalSetup(config: FullConfig) {
const browser = await chromium.launch();
const page = await browser.newPage();
// 执行登录
await page.goto('http://localhost:3000/login');
await page.getByLabel('Email').fill('[email protected]');
await page.getByLabel('Password').fill('admin123');
await page.getByRole('button', { name: 'Login' }).click();
await page.waitForURL('/dashboard');
// 保存认证状态
await page.context().storageState({ path: 'e2e/.auth/admin.json' });
await browser.close();
}
七、Page Object Model
7.1 POM 设计
// e2e/pages/login.page.ts
import type { Page, Locator } from '@playwright/test';
export class LoginPage {
readonly page: Page;
readonly emailInput: Locator;
readonly passwordInput: Locator;
readonly submitButton: Locator;
readonly errorMessage: Locator;
constructor(page: Page) {
this.page = page;
this.emailInput = page.getByLabel('Email');
this.passwordInput = page.getByLabel('Password');
this.submitButton = page.getByRole('button', { name: /sign in/i });
this.errorMessage = page.getByRole('alert');
}
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);
}
}
7.2 在测试中使用
import { test, expect } from '@playwright/test';
import { LoginPage } from './pages/login.page';
test.describe('Login', () => {
let loginPage: LoginPage;
test.beforeEach(async ({ page }) => {
loginPage = new LoginPage(page);
await loginPage.goto();
});
test('should login successfully', async ({ page }) => {
await loginPage.login('[email protected]', 'password');
await expect(page).toHaveURL('/dashboard');
});
test('should show error for invalid credentials', async () => {
await loginPage.login('[email protected]', 'wrong');
await loginPage.expectError('Invalid email or password');
});
});
八、调试与 Trace
8.1 Trace Viewer
Playwright 的 Trace Viewer 是最强大的调试工具,记录测试执行的完整时间线:
// playwright.config.ts
use: {
trace: 'on-first-retry', // 仅在重试时记录(推荐)
// trace: 'on', // 始终记录(开发时)
// trace: 'retain-on-failure', // 失败时保留
}
# 查看 trace
npx playwright show-trace test-results/auth-Login-should-login/trace.zip
Trace 包含:
- 每一步操作的截图和 DOM 快照
- 网络请求时间线
- Console 日志
- 操作耗时
8.2 UI Mode
npx playwright test --ui
交互式 UI 模式:实时运行测试、查看步骤、时间旅行调试。
8.3 Debug Mode
# 以 headed 模式 + Inspector 运行
npx playwright test --debug
# 在代码中设置断点
await page.pause(); // 打开 Playwright Inspector
8.4 Codegen — 代码生成
npx playwright codegen http://localhost:3000
录制浏览器操作,自动生成测试代码。
九、CI/CD 集成
9.1 GitHub Actions
# .github/workflows/e2e.yml
name: E2E Tests
on: [push, pull_request]
jobs:
e2e:
runs-on: ubuntu-latest
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
- name: Build application
run: npm run build
- name: Run E2E tests
run: npx playwright test
- name: Upload test results
uses: actions/upload-artifact@v4
if: ${{ !cancelled() }}
with:
name: playwright-report
path: playwright-report/
retention-days: 30
9.2 Sharding — 大型测试分片
strategy:
matrix:
shard: [1/4, 2/4, 3/4, 4/4]
steps:
- run: npx playwright test --shard ${{ matrix.shard }}
十、面试高频题
题型 1:Playwright 的 Auto-waiting 是如何工作的?
Playwright 的每个操作(click、fill、check 等)都会自动执行 Actionability Checks:
- Attached:元素在 DOM 中
- Visible:元素可见
- Stable:元素位置不再变化(动画结束)
- Enabled:元素非 disabled
- Receives Events:没有被其他元素遮挡
如果条件不满足,Playwright 会自动重试直到超时。这消除了手动 sleep 和 waitFor 的需求。
题型 2:如何处理 Flaky E2E Tests?
- 网络不稳定 → 使用
page.route()Mock 关键 API - 动画干扰 → 全局禁用动画:
page.emulateMedia({ reducedMotion: 'reduce' }) - 时间依赖 → 使用
page.clockMock 时间 - 数据依赖 → 每个测试独立准备数据
- 重试机制 → 配置
retries: 2+trace: 'on-first-retry' - 并行隔离 → 确保测试不共享外部状态
题型 3:E2E 测试 vs 组件测试,如何选择?
- 组件测试:验证组件级别的行为、渲染逻辑、状态变化。快速、稳定、成本低。
- E2E 测试:验证跨页面的完整业务流程。慢、脆弱、但覆盖真实用户路径。
- 原则:先用组件测试覆盖所有组件行为,用 E2E 测试覆盖 核心业务 Happy Path。
与其他主题的关联
| 关联主题 | 关系 |
|---|---|
| testing-philosophy | E2E 是测试金字塔的顶层 |
| component-testing | 组件测试覆盖不了的跨页面流程由 E2E 补充 |
| visual-regression | Playwright 内置 screenshot comparison 支持视觉回归 |
| mocking-strategies | page.route() 是 E2E 级别的 Mock 方案 |
| error-monitoring | E2E 可模拟错误场景验证错误边界 |
参考资料
- Playwright 官方文档 — playwright.dev
- Playwright GitHub — github.com/microsoft/playwright
- Playwright Best Practices — playwright.dev/docs/best-practices
- Debbie O'Brien, Playwright Tips — Microsoft DevRel 团队的实践分享
- Playwright Trace Viewer — playwright.dev/docs/trace-viewer