playwright-e2e-testing
Compare original and translation side by side
🇺🇸
Original
English🇨🇳
Translation
ChinesePlaywright E2E Testing Skill
Playwright 端到端测试技能
progressive_disclosure: entry_point: summary: "Modern E2E testing framework with cross-browser automation and built-in test runner" when_to_use: - "When testing web applications end-to-end" - "When needing cross-browser testing" - "When testing user flows and interactions" - "When needing screenshot/video recording" quick_start: - "npm init playwright@latest" - "Choose TypeScript and test location" - "npx playwright test" - "npx playwright show-report" token_estimate: entry: 75-90 full: 4200-5200
<!-- ENTRY POINT - Load this section by default (75-90 tokens) -->progressive_disclosure: entry_point: summary: "现代化端到端测试框架,支持跨浏览器自动化及内置测试运行器" when_to_use: - "对Web应用进行端到端测试时" - "需要跨浏览器测试时" - "测试用户流程与交互时" - "需要截图/录屏功能时" quick_start: - "npm init playwright@latest" - "选择TypeScript和测试存放位置" - "npx playwright test" - "npx playwright show-report" token_estimate: entry: 75-90 full: 4200-5200
<!-- 入口部分 - 默认加载此部分(75-90 tokens) -->Overview
概述
Playwright is a modern end-to-end testing framework that provides cross-browser automation with a built-in test runner, auto-wait mechanisms, and excellent developer experience.
Playwright是一款现代化的端到端测试框架,提供跨浏览器自动化能力,内置测试运行器、自动等待机制,为开发者带来出色的使用体验。
Key Features
核心特性
- Auto-wait: Automatically waits for elements to be ready
- Cross-browser: Chromium, Firefox, WebKit support
- Built-in runner: Parallel execution, retries, reporters
- Network control: Mock and intercept network requests
- Debugging: UI mode, trace viewer, inspector
<!-- FULL CONTENT - Load on demand (4200-5200 tokens) -->
- 自动等待:自动等待元素准备就绪
- 跨浏览器支持:兼容Chromium、Firefox、WebKit
- 内置运行器:支持并行执行、重试、报告生成
- 网络控制:可模拟和拦截网络请求
- 调试工具:UI模式、追踪查看器、检查器
<!-- 完整内容 - 按需加载(4200-5200 tokens) -->
Installation
安装
bash
undefinedbash
undefinedInitialize new Playwright project
初始化新的Playwright项目
npm init playwright@latest
npm init playwright@latest
Or add to existing project
或添加到现有项目
npm install -D @playwright/test
npm install -D @playwright/test
Install browsers
安装浏览器
npx playwright install
undefinednpx playwright install
undefinedConfiguration
配置
typescript
// playwright.config.ts
import { defineConfig, devices } from '@playwright/test';
export default defineConfig({
testDir: './tests',
fullyParallel: true,
forbidOnly: !!process.env.CI,
retries: process.env.CI ? 2 : 0,
workers: process.env.CI ? 1 : undefined,
reporter: 'html',
use: {
baseURL: 'http://localhost:3000',
trace: 'on-first-retry',
screenshot: 'only-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 start',
url: 'http://localhost:3000',
reuseExistingServer: !process.env.CI,
},
});typescript
// playwright.config.ts
import { defineConfig, devices } from '@playwright/test';
export default defineConfig({
testDir: './tests',
fullyParallel: true,
forbidOnly: !!process.env.CI,
retries: process.env.CI ? 2 : 0,
workers: process.env.CI ? 1 : undefined,
reporter: 'html',
use: {
baseURL: 'http://localhost:3000',
trace: 'on-first-retry',
screenshot: 'only-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 start',
url: 'http://localhost:3000',
reuseExistingServer: !process.env.CI,
},
});Fundamentals
基础内容
Basic Test Structure
基础测试结构
typescript
import { test, expect } from '@playwright/test';
test('basic test', async ({ page }) => {
await page.goto('https://example.com');
// Wait for element and check visibility
const title = page.locator('h1');
await expect(title).toBeVisible();
await expect(title).toHaveText('Example Domain');
// Get page title
await expect(page).toHaveTitle(/Example/);
});
test.describe('User authentication', () => {
test('should login successfully', async ({ page }) => {
await page.goto('/login');
await page.fill('[name="username"]', 'testuser');
await page.fill('[name="password"]', 'password123');
await page.click('button[type="submit"]');
await expect(page).toHaveURL('/dashboard');
await expect(page.locator('.welcome-message')).toContainText('Welcome');
});
test('should show error for invalid credentials', async ({ page }) => {
await page.goto('/login');
await page.fill('[name="username"]', 'invalid');
await page.fill('[name="password"]', 'wrong');
await page.click('button[type="submit"]');
await expect(page.locator('.error-message')).toBeVisible();
await expect(page.locator('.error-message')).toHaveText('Invalid credentials');
});
});typescript
import { test, expect } from '@playwright/test';
test('basic test', async ({ page }) => {
await page.goto('https://example.com');
// 等待元素并检查可见性
const title = page.locator('h1');
await expect(title).toBeVisible();
await expect(title).toHaveText('Example Domain');
// 获取页面标题
await expect(page).toHaveTitle(/Example/);
});
test.describe('User authentication', () => {
test('should login successfully', async ({ page }) => {
await page.goto('/login');
await page.fill('[name="username"]', 'testuser');
await page.fill('[name="password"]', 'password123');
await page.click('button[type="submit"]');
await expect(page).toHaveURL('/dashboard');
await expect(page.locator('.welcome-message')).toContainText('Welcome');
});
test('should show error for invalid credentials', async ({ page }) => {
await page.goto('/login');
await page.fill('[name="username"]', 'invalid');
await page.fill('[name="password"]', 'wrong');
await page.click('button[type="submit"]');
await expect(page.locator('.error-message')).toBeVisible();
await expect(page.locator('.error-message')).toHaveText('Invalid credentials');
});
});Test Hooks
测试钩子
typescript
import { test, expect } from '@playwright/test';
test.describe('Dashboard tests', () => {
test.beforeEach(async ({ page }) => {
// Run before each test
await page.goto('/dashboard');
await page.waitForLoadState('networkidle');
});
test.afterEach(async ({ page }) => {
// Cleanup after each test
await page.close();
});
test.beforeAll(async ({ browser }) => {
// Run once before all tests in describe block
console.log('Starting test suite');
});
test.afterAll(async ({ browser }) => {
// Run once after all tests
console.log('Test suite complete');
});
test('displays user data', async ({ page }) => {
await expect(page.locator('.user-name')).toBeVisible();
});
});typescript
import { test, expect } from '@playwright/test';
test.describe('Dashboard tests', () => {
test.beforeEach(async ({ page }) => {
// 在每个测试前运行
await page.goto('/dashboard');
await page.waitForLoadState('networkidle');
});
test.afterEach(async ({ page }) => {
// 每个测试后清理
await page.close();
});
test.beforeAll(async ({ browser }) => {
// 在describe块的所有测试前运行一次
console.log('Starting test suite');
});
test.afterAll(async ({ browser }) => {
// 在所有测试完成后运行一次
console.log('Test suite complete');
});
test('displays user data', async ({ page }) => {
await expect(page.locator('.user-name')).toBeVisible();
});
});Locator Strategies
定位器策略
Best Practice: Role-based Locators
最佳实践:基于角色的定位器
typescript
import { test, expect } from '@playwright/test';
test('accessible locators', async ({ page }) => {
await page.goto('/form');
// By role (BEST - accessible and stable)
await page.getByRole('button', { name: 'Submit' }).click();
await page.getByRole('textbox', { name: 'Email' }).fill('user@example.com');
await page.getByRole('checkbox', { name: 'Subscribe' }).check();
await page.getByRole('link', { name: 'Learn more' }).click();
// By label (good for forms)
await page.getByLabel('Password').fill('secret123');
// By placeholder
await page.getByPlaceholder('Search...').fill('query');
// By text
await page.getByText('Welcome back').click();
await page.getByText(/hello/i).isVisible();
// By test ID (good for dynamic content)
await page.getByTestId('user-profile').click();
// By title
await page.getByTitle('Close dialog').click();
// By alt text (images)
await page.getByAltText('User avatar').click();
});typescript
import { test, expect } from '@playwright/test';
test('accessible locators', async ({ page }) => {
await page.goto('/form');
// 按角色定位(最佳方式 - 可访问且稳定)
await page.getByRole('button', { name: 'Submit' }).click();
await page.getByRole('textbox', { name: 'Email' }).fill('user@example.com');
await page.getByRole('checkbox', { name: 'Subscribe' }).check();
await page.getByRole('link', { name: 'Learn more' }).click();
// 按标签定位(适合表单)
await page.getByLabel('Password').fill('secret123');
// 按占位符定位
await page.getByPlaceholder('Search...').fill('query');
// 按文本定位
await page.getByText('Welcome back').click();
await page.getByText(/hello/i).isVisible();
// 按测试ID定位(适合动态内容)
await page.getByTestId('user-profile').click();
// 按标题定位
await page.getByTitle('Close dialog').click();
// 按替代文本定位(图片)
await page.getByAltText('User avatar').click();
});CSS and XPath Locators
CSS和XPath定位器
typescript
test('CSS and XPath locators', async ({ page }) => {
// CSS selectors
await page.locator('button.primary').click();
await page.locator('#user-menu').click();
await page.locator('[data-testid="submit-btn"]').click();
await page.locator('div.card:first-child').click();
// XPath (use sparingly)
await page.locator('xpath=//button[contains(text(), "Submit")]').click();
// Chaining locators
const form = page.locator('form#login-form');
await form.locator('input[name="email"]').fill('user@example.com');
await form.locator('button[type="submit"]').click();
// Filter locators
await page.getByRole('listitem')
.filter({ hasText: 'Product 1' })
.getByRole('button', { name: 'Add to cart' })
.click();
});typescript
test('CSS and XPath locators', async ({ page }) => {
// CSS选择器
await page.locator('button.primary').click();
await page.locator('#user-menu').click();
await page.locator('[data-testid="submit-btn"]').click();
await page.locator('div.card:first-child').click();
// XPath(谨慎使用)
await page.locator('xpath=//button[contains(text(), "Submit")]').click();
// 链式定位器
const form = page.locator('form#login-form');
await form.locator('input[name="email"]').fill('user@example.com');
await form.locator('button[type="submit"]').click();
// 过滤定位器
await page.getByRole('listitem')
.filter({ hasText: 'Product 1' })
.getByRole('button', { name: 'Add to cart' })
.click();
});Page Object Model
页面对象模型
Page Class Pattern
页面类模式
typescript
// pages/LoginPage.ts
import { Page, Locator } from '@playwright/test';
export class LoginPage {
readonly page: Page;
readonly usernameInput: Locator;
readonly passwordInput: Locator;
readonly submitButton: Locator;
readonly errorMessage: Locator;
constructor(page: Page) {
this.page = page;
this.usernameInput = page.getByLabel('Username');
this.passwordInput = page.getByLabel('Password');
this.submitButton = page.getByRole('button', { name: 'Log in' });
this.errorMessage = page.locator('.error-message');
}
async goto() {
await this.page.goto('/login');
}
async login(username: string, password: string) {
await this.usernameInput.fill(username);
await this.passwordInput.fill(password);
await this.submitButton.click();
}
async expectErrorMessage(message: string) {
await this.errorMessage.waitFor({ state: 'visible' });
await expect(this.errorMessage).toHaveText(message);
}
}
// pages/DashboardPage.ts
export class DashboardPage {
readonly page: Page;
readonly welcomeMessage: Locator;
readonly logoutButton: Locator;
constructor(page: Page) {
this.page = page;
this.welcomeMessage = page.locator('.welcome-message');
this.logoutButton = page.getByRole('button', { name: 'Logout' });
}
async waitForLoad() {
await this.welcomeMessage.waitFor({ state: 'visible' });
}
async logout() {
await this.logoutButton.click();
}
}
// tests/auth.spec.ts
import { test, expect } from '@playwright/test';
import { LoginPage } from '../pages/LoginPage';
import { DashboardPage } from '../pages/DashboardPage';
test('successful login flow', async ({ page }) => {
const loginPage = new LoginPage(page);
const dashboard = new DashboardPage(page);
await loginPage.goto();
await loginPage.login('testuser', 'password123');
await dashboard.waitForLoad();
await expect(dashboard.welcomeMessage).toContainText('Welcome');
});typescript
// pages/LoginPage.ts
import { Page, Locator } from '@playwright/test';
export class LoginPage {
readonly page: Page;
readonly usernameInput: Locator;
readonly passwordInput: Locator;
readonly submitButton: Locator;
readonly errorMessage: Locator;
constructor(page: Page) {
this.page = page;
this.usernameInput = page.getByLabel('Username');
this.passwordInput = page.getByLabel('Password');
this.submitButton = page.getByRole('button', { name: 'Log in' });
this.errorMessage = page.locator('.error-message');
}
async goto() {
await this.page.goto('/login');
}
async login(username: string, password: string) {
await this.usernameInput.fill(username);
await this.passwordInput.fill(password);
await this.submitButton.click();
}
async expectErrorMessage(message: string) {
await this.errorMessage.waitFor({ state: 'visible' });
await expect(this.errorMessage).toHaveText(message);
}
}
// pages/DashboardPage.ts
export class DashboardPage {
readonly page: Page;
readonly welcomeMessage: Locator;
readonly logoutButton: Locator;
constructor(page: Page) {
this.page = page;
this.welcomeMessage = page.locator('.welcome-message');
this.logoutButton = page.getByRole('button', { name: 'Logout' });
}
async waitForLoad() {
await this.welcomeMessage.waitFor({ state: 'visible' });
}
async logout() {
await this.logoutButton.click();
}
}
// tests/auth.spec.ts
import { test, expect } from '@playwright/test';
import { LoginPage } from '../pages/LoginPage';
import { DashboardPage } from '../pages/DashboardPage';
test('successful login flow', async ({ page }) => {
const loginPage = new LoginPage(page);
const dashboard = new DashboardPage(page);
await loginPage.goto();
await loginPage.login('testuser', 'password123');
await dashboard.waitForLoad();
await expect(dashboard.welcomeMessage).toContainText('Welcome');
});Component Pattern
组件模式
typescript
// components/NavigationComponent.ts
import { Page, Locator } from '@playwright/test';
export class NavigationComponent {
readonly page: Page;
readonly homeLink: Locator;
readonly profileLink: Locator;
readonly searchInput: Locator;
constructor(page: Page) {
this.page = page;
const nav = page.locator('nav');
this.homeLink = nav.getByRole('link', { name: 'Home' });
this.profileLink = nav.getByRole('link', { name: 'Profile' });
this.searchInput = nav.getByPlaceholder('Search...');
}
async navigateToProfile() {
await this.profileLink.click();
}
async search(query: string) {
await this.searchInput.fill(query);
await this.searchInput.press('Enter');
}
}typescript
// components/NavigationComponent.ts
import { Page, Locator } from '@playwright/test';
export class NavigationComponent {
readonly page: Page;
readonly homeLink: Locator;
readonly profileLink: Locator;
readonly searchInput: Locator;
constructor(page: Page) {
this.page = page;
const nav = page.locator('nav');
this.homeLink = nav.getByRole('link', { name: 'Home' });
this.profileLink = nav.getByRole('link', { name: 'Profile' });
this.searchInput = nav.getByPlaceholder('Search...');
}
async navigateToProfile() {
await this.profileLink.click();
}
async search(query: string) {
await this.searchInput.fill(query);
await this.searchInput.press('Enter');
}
}User Interactions
用户交互
Form Interactions
表单交互
typescript
test('form interactions', async ({ page }) => {
await page.goto('/form');
// Text inputs
await page.fill('input[name="email"]', 'user@example.com');
await page.type('textarea[name="message"]', 'Hello', { delay: 100 });
// Checkboxes
await page.check('input[type="checkbox"][name="subscribe"]');
await page.uncheck('input[type="checkbox"][name="spam"]');
// Radio buttons
await page.check('input[type="radio"][value="option1"]');
// Select dropdowns
await page.selectOption('select[name="country"]', 'US');
await page.selectOption('select[name="color"]', { label: 'Blue' });
await page.selectOption('select[name="size"]', { value: 'large' });
// Multi-select
await page.selectOption('select[multiple]', ['value1', 'value2']);
// File uploads
await page.setInputFiles('input[type="file"]', 'path/to/file.pdf');
await page.setInputFiles('input[type="file"]', [
'file1.jpg',
'file2.jpg'
]);
// Clear file input
await page.setInputFiles('input[type="file"]', []);
});typescript
test('form interactions', async ({ page }) => {
await page.goto('/form');
// 文本输入
await page.fill('input[name="email"]', 'user@example.com');
await page.type('textarea[name="message"]', 'Hello', { delay: 100 });
// 复选框
await page.check('input[type="checkbox"][name="subscribe"]');
await page.uncheck('input[type="checkbox"][name="spam"]');
// 单选按钮
await page.check('input[type="radio"][value="option1"]');
// 下拉选择框
await page.selectOption('select[name="country"]', 'US');
await page.selectOption('select[name="color"]', { label: 'Blue' });
await page.selectOption('select[name="size"]', { value: 'large' });
// 多选框
await page.selectOption('select[multiple]', ['value1', 'value2']);
// 文件上传
await page.setInputFiles('input[type="file"]', 'path/to/file.pdf');
await page.setInputFiles('input[type="file"]', [
'file1.jpg',
'file2.jpg'
]);
// 清空文件输入
await page.setInputFiles('input[type="file"]', []);
});Mouse and Keyboard
鼠标与键盘操作
typescript
test('mouse and keyboard interactions', async ({ page }) => {
// Click variations
await page.click('button');
await page.dblclick('button'); // Double click
await page.click('button', { button: 'right' }); // Right click
await page.click('button', { modifiers: ['Shift'] }); // Shift+click
// Hover
await page.hover('.tooltip-trigger');
await expect(page.locator('.tooltip')).toBeVisible();
// Drag and drop
await page.dragAndDrop('#draggable', '#droppable');
// Keyboard
await page.keyboard.press('Enter');
await page.keyboard.press('Control+A');
await page.keyboard.type('Hello World');
await page.keyboard.down('Shift');
await page.keyboard.press('ArrowDown');
await page.keyboard.up('Shift');
// Focus
await page.focus('input[name="email"]');
await page.fill('input[name="email"]', 'test@example.com');
});typescript
test('mouse and keyboard interactions', async ({ page }) => {
// 点击变体
await page.click('button');
await page.dblclick('button'); // 双击
await page.click('button', { button: 'right' }); // 右键点击
await page.click('button', { modifiers: ['Shift'] }); // Shift+点击
// 悬停
await page.hover('.tooltip-trigger');
await expect(page.locator('.tooltip')).toBeVisible();
// 拖拽
await page.dragAndDrop('#draggable', '#droppable');
// 键盘操作
await page.keyboard.press('Enter');
await page.keyboard.press('Control+A');
await page.keyboard.type('Hello World');
await page.keyboard.down('Shift');
await page.keyboard.press('ArrowDown');
await page.keyboard.up('Shift');
// 聚焦
await page.focus('input[name="email"]');
await page.fill('input[name="email"]', 'test@example.com');
});Waiting Strategies
等待策略
typescript
test('waiting strategies', async ({ page }) => {
// Wait for element
await page.waitForSelector('.dynamic-content');
await page.waitForSelector('.modal', { state: 'visible' });
await page.waitForSelector('.loading', { state: 'hidden' });
// Wait for load state
await page.waitForLoadState('load');
await page.waitForLoadState('domcontentloaded');
await page.waitForLoadState('networkidle');
// Wait for URL
await page.waitForURL('**/dashboard');
await page.waitForURL(/\/product\/\d+/);
// Wait for function
await page.waitForFunction(() => {
return document.querySelectorAll('.item').length > 5;
});
// Wait for timeout (avoid if possible)
await page.waitForTimeout(1000);
// Wait for event
await page.waitForEvent('load');
await page.waitForEvent('popup');
});typescript
test('waiting strategies', async ({ page }) => {
// 等待元素
await page.waitForSelector('.dynamic-content');
await page.waitForSelector('.modal', { state: 'visible' });
await page.waitForSelector('.loading', { state: 'hidden' });
// 等待加载状态
await page.waitForLoadState('load');
await page.waitForLoadState('domcontentloaded');
await page.waitForLoadState('networkidle');
// 等待URL
await page.waitForURL('**/dashboard');
await page.waitForURL(/\/product\/\d+/);
// 等待函数执行
await page.waitForFunction(() => {
return document.querySelectorAll('.item').length > 5;
});
// 等待超时(尽可能避免)
await page.waitForTimeout(1000);
// 等待事件
await page.waitForEvent('load');
await page.waitForEvent('popup');
});Assertions
断言
Common Assertions
常见断言
typescript
import { test, expect } from '@playwright/test';
test('assertions', async ({ page }) => {
await page.goto('/dashboard');
// Visibility
await expect(page.locator('.header')).toBeVisible();
await expect(page.locator('.loading')).toBeHidden();
await expect(page.locator('.optional')).not.toBeVisible();
// Text content
await expect(page.locator('h1')).toHaveText('Dashboard');
await expect(page.locator('h1')).toContainText('Dash');
await expect(page.locator('.message')).toHaveText(/welcome/i);
// Attributes
await expect(page.locator('button')).toBeEnabled();
await expect(page.locator('button')).toBeDisabled();
await expect(page.locator('input')).toHaveAttribute('type', 'email');
await expect(page.locator('input')).toHaveValue('test@example.com');
// CSS
await expect(page.locator('.button')).toHaveClass('btn-primary');
await expect(page.locator('.button')).toHaveClass(/btn-/);
await expect(page.locator('.element')).toHaveCSS('color', 'rgb(255, 0, 0)');
// Count
await expect(page.locator('.item')).toHaveCount(5);
// URL and title
await expect(page).toHaveURL('http://localhost:3000/dashboard');
await expect(page).toHaveURL(/dashboard$/);
await expect(page).toHaveTitle('Dashboard - My App');
await expect(page).toHaveTitle(/Dashboard/);
// Screenshot comparison
await expect(page).toHaveScreenshot('dashboard.png');
await expect(page.locator('.widget')).toHaveScreenshot('widget.png');
});typescript
import { test, expect } from '@playwright/test';
test('assertions', async ({ page }) => {
await page.goto('/dashboard');
// 可见性
await expect(page.locator('.header')).toBeVisible();
await expect(page.locator('.loading')).toBeHidden();
await expect(page.locator('.optional')).not.toBeVisible();
// 文本内容
await expect(page.locator('h1')).toHaveText('Dashboard');
await expect(page.locator('h1')).toContainText('Dash');
await expect(page.locator('.message')).toHaveText(/welcome/i);
// 属性
await expect(page.locator('button')).toBeEnabled();
await expect(page.locator('button')).toBeDisabled();
await expect(page.locator('input')).toHaveAttribute('type', 'email');
await expect(page.locator('input')).toHaveValue('test@example.com');
// CSS
await expect(page.locator('.button')).toHaveClass('btn-primary');
await expect(page.locator('.button')).toHaveClass(/btn-/);
await expect(page.locator('.element')).toHaveCSS('color', 'rgb(255, 0, 0)');
// 数量
await expect(page.locator('.item')).toHaveCount(5);
// URL与标题
await expect(page).toHaveURL('http://localhost:3000/dashboard');
await expect(page).toHaveURL(/dashboard$/);
await expect(page).toHaveTitle('Dashboard - My App');
await expect(page).toHaveTitle(/Dashboard/);
// 截图对比
await expect(page).toHaveScreenshot('dashboard.png');
await expect(page.locator('.widget')).toHaveScreenshot('widget.png');
});Custom Assertions
自定义断言
typescript
test('custom matchers', async ({ page }) => {
// Soft assertions (continue test on failure)
await expect.soft(page.locator('.title')).toHaveText('Welcome');
await expect.soft(page.locator('.subtitle')).toBeVisible();
// Multiple elements
const items = page.locator('.item');
await expect(items).toHaveCount(3);
await expect(items.nth(0)).toContainText('First');
await expect(items.nth(1)).toContainText('Second');
// Poll assertions
await expect(async () => {
const response = await page.request.get('/api/status');
expect(response.ok()).toBeTruthy();
}).toPass({
timeout: 10000,
intervals: [1000, 2000, 5000],
});
});typescript
test('custom matchers', async ({ page }) => {
// 软断言(失败后继续执行测试)
await expect.soft(page.locator('.title')).toHaveText('Welcome');
await expect.soft(page.locator('.subtitle')).toBeVisible();
// 多元素断言
const items = page.locator('.item');
await expect(items).toHaveCount(3);
await expect(items.nth(0)).toContainText('First');
await expect(items.nth(1)).toContainText('Second');
// 轮询断言
await expect(async () => {
const response = await page.request.get('/api/status');
expect(response.ok()).toBeTruthy();
}).toPass({
timeout: 10000,
intervals: [1000, 2000, 5000],
});
});Authentication Patterns
认证模式
Storage State Pattern
存储状态模式
typescript
// auth.setup.ts - Run once to save auth state
import { test as setup } from '@playwright/test';
const authFile = 'playwright/.auth/user.json';
setup('authenticate', async ({ page }) => {
await page.goto('/login');
await page.fill('[name="username"]', 'testuser');
await page.fill('[name="password"]', 'password123');
await page.click('button[type="submit"]');
await page.waitForURL('/dashboard');
// Save authentication state
await page.context().storageState({ path: authFile });
});
// playwright.config.ts
export default defineConfig({
projects: [
{
name: 'setup',
testMatch: /.*\.setup\.ts/,
},
{
name: 'chromium',
use: {
...devices['Desktop Chrome'],
storageState: authFile,
},
dependencies: ['setup'],
},
],
});
// tests/dashboard.spec.ts - Already authenticated
test('view dashboard', async ({ page }) => {
await page.goto('/dashboard');
// Already logged in!
await expect(page.locator('.user-menu')).toBeVisible();
});typescript
// auth.setup.ts - 运行一次以保存认证状态
import { test as setup } from '@playwright/test';
const authFile = 'playwright/.auth/user.json';
setup('authenticate', async ({ page }) => {
await page.goto('/login');
await page.fill('[name="username"]', 'testuser');
await page.fill('[name="password"]', 'password123');
await page.click('button[type="submit"]');
await page.waitForURL('/dashboard');
// 保存认证状态
await page.context().storageState({ path: authFile });
});
// playwright.config.ts
export default defineConfig({
projects: [
{
name: 'setup',
testMatch: /.*\.setup\.ts/,
},
{
name: 'chromium',
use: {
...devices['Desktop Chrome'],
storageState: authFile,
},
dependencies: ['setup'],
},
],
});
// tests/dashboard.spec.ts - 已认证状态
test('view dashboard', async ({ page }) => {
await page.goto('/dashboard');
// 已登录状态!
await expect(page.locator('.user-menu')).toBeVisible();
});Multiple User Roles
多用户角色
typescript
// fixtures/auth.ts
import { test as base } from '@playwright/test';
type Fixtures = {
adminPage: Page;
userPage: Page;
};
export const test = base.extend<Fixtures>({
adminPage: async ({ browser }, use) => {
const context = await browser.newContext({
storageState: 'playwright/.auth/admin.json',
});
const page = await context.newPage();
await use(page);
await context.close();
},
userPage: async ({ browser }, use) => {
const context = await browser.newContext({
storageState: 'playwright/.auth/user.json',
});
const page = await context.newPage();
await use(page);
await context.close();
},
});
// tests/permissions.spec.ts
import { test } from '../fixtures/auth';
test('admin can access admin panel', async ({ adminPage }) => {
await adminPage.goto('/admin');
await expect(adminPage.locator('.admin-panel')).toBeVisible();
});
test('regular user cannot access admin panel', async ({ userPage }) => {
await userPage.goto('/admin');
await expect(userPage.locator('.access-denied')).toBeVisible();
});typescript
// fixtures/auth.ts
import { test as base } from '@playwright/test';
type Fixtures = {
adminPage: Page;
userPage: Page;
};
export const test = base.extend<Fixtures>({
adminPage: async ({ browser }, use) => {
const context = await browser.newContext({
storageState: 'playwright/.auth/admin.json',
});
const page = await context.newPage();
await use(page);
await context.close();
},
userPage: async ({ browser }, use) => {
const context = await browser.newContext({
storageState: 'playwright/.auth/user.json',
});
const page = await context.newPage();
await use(page);
await context.close();
},
});
// tests/permissions.spec.ts
import { test } from '../fixtures/auth';
test('admin can access admin panel', async ({ adminPage }) => {
await adminPage.goto('/admin');
await expect(adminPage.locator('.admin-panel')).toBeVisible();
});
test('regular user cannot access admin panel', async ({ userPage }) => {
await userPage.goto('/admin');
await expect(userPage.locator('.access-denied')).toBeVisible();
});Network Control
网络控制
Request Mocking
请求模拟
typescript
test('mock API responses', async ({ page }) => {
// Mock API response
await page.route('**/api/users', route => {
route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({
users: [
{ id: 1, name: 'John Doe' },
{ id: 2, name: 'Jane Smith' },
],
}),
});
});
await page.goto('/users');
await expect(page.locator('.user-list')).toContainText('John Doe');
});
test('mock with conditions', async ({ page }) => {
await page.route('**/api/**', route => {
const url = route.request().url();
if (url.includes('/users/1')) {
route.fulfill({
status: 200,
body: JSON.stringify({ id: 1, name: 'Test User' }),
});
} else if (url.includes('/users')) {
route.fulfill({
status: 200,
body: JSON.stringify({ users: [] }),
});
} else {
route.continue();
}
});
});
test('simulate network errors', async ({ page }) => {
await page.route('**/api/data', route => {
route.abort('failed');
});
await page.goto('/data');
await expect(page.locator('.error-message')).toBeVisible();
});typescript
test('mock API responses', async ({ page }) => {
// 模拟API响应
await page.route('**/api/users', route => {
route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({
users: [
{ id: 1, name: 'John Doe' },
{ id: 2, name: 'Jane Smith' },
],
}),
});
});
await page.goto('/users');
await expect(page.locator('.user-list')).toContainText('John Doe');
});
test('mock with conditions', async ({ page }) => {
await page.route('**/api/**', route => {
const url = route.request().url();
if (url.includes('/users/1')) {
route.fulfill({
status: 200,
body: JSON.stringify({ id: 1, name: 'Test User' }),
});
} else if (url.includes('/users')) {
route.fulfill({
status: 200,
body: JSON.stringify({ users: [] }),
});
} else {
route.continue();
}
});
});
test('simulate network errors', async ({ page }) => {
await page.route('**/api/data', route => {
route.abort('failed');
});
await page.goto('/data');
await expect(page.locator('.error-message')).toBeVisible();
});Request Interception
请求拦截
typescript
test('intercept and modify requests', async ({ page }) => {
// Modify request headers
await page.route('**/api/**', route => {
const headers = route.request().headers();
route.continue({
headers: {
...headers,
'X-Custom-Header': 'test-value',
},
});
});
// Modify POST data
await page.route('**/api/submit', route => {
const postData = route.request().postDataJSON();
route.continue({
postData: JSON.stringify({
...postData,
timestamp: Date.now(),
}),
});
});
});
test('wait for API response', async ({ page }) => {
// Wait for specific request
const responsePromise = page.waitForResponse('**/api/users');
await page.click('button#load-users');
const response = await responsePromise;
expect(response.status()).toBe(200);
const data = await response.json();
expect(data.users).toHaveLength(10);
});typescript
test('intercept and modify requests', async ({ page }) => {
// 修改请求头
await page.route('**/api/**', route => {
const headers = route.request().headers();
route.continue({
headers: {
...headers,
'X-Custom-Header': 'test-value',
},
});
});
// 修改POST数据
await page.route('**/api/submit', route => {
const postData = route.request().postDataJSON();
route.continue({
postData: JSON.stringify({
...postData,
timestamp: Date.now(),
}),
});
});
});
test('wait for API response', async ({ page }) => {
// 等待特定请求
const responsePromise = page.waitForResponse('**/api/users');
await page.click('button#load-users');
const response = await responsePromise;
expect(response.status()).toBe(200);
const data = await response.json();
expect(data.users).toHaveLength(10);
});Test Organization
测试组织
Custom Fixtures
自定义夹具
typescript
// fixtures/todos.ts
import { test as base } from '@playwright/test';
type TodoFixtures = {
todoPage: TodoPage;
createTodo: (title: string) => Promise<void>;
};
export const test = base.extend<TodoFixtures>({
todoPage: async ({ page }, use) => {
const todoPage = new TodoPage(page);
await todoPage.goto();
await use(todoPage);
},
createTodo: async ({ page }, use) => {
const create = async (title: string) => {
await page.fill('.new-todo', title);
await page.press('.new-todo', 'Enter');
};
await use(create);
},
});
// tests/todos.spec.ts
import { test } from '../fixtures/todos';
test('can create new todo', async ({ todoPage, createTodo }) => {
await createTodo('Buy groceries');
await expect(todoPage.todoItems).toHaveCount(1);
await expect(todoPage.todoItems).toHaveText('Buy groceries');
});typescript
// fixtures/todos.ts
import { test as base } from '@playwright/test';
type TodoFixtures = {
todoPage: TodoPage;
createTodo: (title: string) => Promise<void>;
};
export const test = base.extend<TodoFixtures>({
todoPage: async ({ page }, use) => {
const todoPage = new TodoPage(page);
await todoPage.goto();
await use(todoPage);
},
createTodo: async ({ page }, use) => {
const create = async (title: string) => {
await page.fill('.new-todo', title);
await page.press('.new-todo', 'Enter');
};
await use(create);
},
});
// tests/todos.spec.ts
import { test } from '../fixtures/todos';
test('can create new todo', async ({ todoPage, createTodo }) => {
await createTodo('Buy groceries');
await expect(todoPage.todoItems).toHaveCount(1);
await expect(todoPage.todoItems).toHaveText('Buy groceries');
});Test Tags and Filtering
测试标签与过滤
typescript
test('smoke test', { tag: '@smoke' }, async ({ page }) => {
await page.goto('/');
await expect(page).toHaveTitle('Home');
});
test('regression test', { tag: ['@regression', '@critical'] }, async ({ page }) => {
// Complex test
});
// Run: npx playwright test --grep @smoke
// Run: npx playwright test --grep-invert @slowtypescript
test('smoke test', { tag: '@smoke' }, async ({ page }) => {
await page.goto('/');
await expect(page).toHaveTitle('Home');
});
test('regression test', { tag: ['@regression', '@critical'] }, async ({ page }) => {
// 复杂测试
});
// 运行:npx playwright test --grep @smoke
// 运行:npx playwright test --grep-invert @slowVisual Testing
可视化测试
Screenshot Comparison
截图对比
typescript
test('visual regression', async ({ page }) => {
await page.goto('/dashboard');
// Full page screenshot
await expect(page).toHaveScreenshot('dashboard.png', {
maxDiffPixels: 100,
});
// Element screenshot
await expect(page.locator('.widget')).toHaveScreenshot('widget.png');
// Full page with scroll
await expect(page).toHaveScreenshot('full-page.png', {
fullPage: true,
});
// Mask dynamic elements
await expect(page).toHaveScreenshot('masked.png', {
mask: [page.locator('.timestamp'), page.locator('.avatar')],
});
// Custom threshold
await expect(page).toHaveScreenshot('comparison.png', {
maxDiffPixelRatio: 0.05, // 5% difference allowed
});
});typescript
test('visual regression', async ({ page }) => {
await page.goto('/dashboard');
// 全页截图
await expect(page).toHaveScreenshot('dashboard.png', {
maxDiffPixels: 100,
});
// 元素截图
await expect(page.locator('.widget')).toHaveScreenshot('widget.png');
// 滚动全页截图
await expect(page).toHaveScreenshot('full-page.png', {
fullPage: true,
});
// 遮罩动态元素
await expect(page).toHaveScreenshot('masked.png', {
mask: [page.locator('.timestamp'), page.locator('.avatar')],
});
// 自定义阈值
await expect(page).toHaveScreenshot('comparison.png', {
maxDiffPixelRatio: 0.05, // 允许5%的差异
});
});Video and Trace
录屏与追踪
typescript
// playwright.config.ts
export default defineConfig({
use: {
video: 'retain-on-failure',
trace: 'on-first-retry',
screenshot: 'only-on-failure',
},
});
// Programmatic video
test('record video', async ({ page }) => {
await page.goto('/');
// Test actions...
// Video saved automatically to test-results/
});
// View trace: npx playwright show-trace trace.ziptypescript
// playwright.config.ts
export default defineConfig({
use: {
video: 'retain-on-failure',
trace: 'on-first-retry',
screenshot: 'only-on-failure',
},
});
// 程序化录屏
test('record video', async ({ page }) => {
await page.goto('/');
// 测试操作...
// 视频自动保存到test-results/
});
// 查看追踪:npx playwright show-trace trace.zipParallel Execution
并行执行
Test Sharding
测试分片
typescript
// playwright.config.ts
export default defineConfig({
fullyParallel: true,
workers: process.env.CI ? 4 : undefined,
});
// Run shards in CI
// npx playwright test --shard=1/4
// npx playwright test --shard=2/4
// npx playwright test --shard=3/4
// npx playwright test --shard=4/4typescript
// playwright.config.ts
export default defineConfig({
fullyParallel: true,
workers: process.env.CI ? 4 : undefined,
});
// 在CI中运行分片
// npx playwright test --shard=1/4
// npx playwright test --shard=2/4
// npx playwright test --shard=3/4
// npx playwright test --shard=4/4Serial Tests
串行测试
typescript
test.describe.configure({ mode: 'serial' });
test.describe('order matters', () => {
let orderId: string;
test('create order', async ({ page }) => {
// Create order
orderId = await createOrder(page);
});
test('verify order', async ({ page }) => {
// Use orderId from previous test
await verifyOrder(page, orderId);
});
});typescript
test.describe.configure({ mode: 'serial' });
test.describe('order matters', () => {
let orderId: string;
test('create order', async ({ page }) => {
// 创建订单
orderId = await createOrder(page);
});
test('verify order', async ({ page }) => {
// 使用前一个测试的orderId
await verifyOrder(page, orderId);
});
});CI/CD Integration
CI/CD集成
GitHub Actions
GitHub Actions
yaml
undefinedyaml
undefined.github/workflows/playwright.yml
.github/workflows/playwright.yml
name: Playwright Tests
on:
push:
branches: [main, master]
pull_request:
branches: [main, master]
jobs:
test:
timeout-minutes: 60
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: Run Playwright tests
run: npx playwright test
- uses: actions/upload-artifact@v4
if: always()
with:
name: playwright-report
path: playwright-report/
retention-days: 30undefinedname: Playwright Tests
on:
push:
branches: [main, master]
pull_request:
branches: [main, master]
jobs:
test:
timeout-minutes: 60
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: Run Playwright tests
run: npx playwright test
- uses: actions/upload-artifact@v4
if: always()
with:
name: playwright-report
path: playwright-report/
retention-days: 30undefinedDocker
Docker
dockerfile
FROM mcr.microsoft.com/playwright:v1.40.0-jammy
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
CMD ["npx", "playwright", "test"]dockerfile
FROM mcr.microsoft.com/playwright:v1.40.0-jammy
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
CMD ["npx", "playwright", "test"]Debugging
调试
UI Mode
UI模式
bash
undefinedbash
undefinedInteractive debugging
交互式调试
npx playwright test --ui
npx playwright test --ui
Debug specific test
调试特定测试
npx playwright test --debug login.spec.ts
npx playwright test --debug login.spec.ts
Step through test
逐步执行测试
npx playwright test --headed --slow-mo=1000
undefinednpx playwright test --headed --slow-mo=1000
undefinedTrace Viewer
追踪查看器
typescript
// Generate trace
test('with trace', async ({ page }) => {
await page.context().tracing.start({ screenshots: true, snapshots: true });
// Test actions
await page.goto('/');
await page.context().tracing.stop({ path: 'trace.zip' });
});
// View: npx playwright show-trace trace.ziptypescript
// 生成追踪文件
test('with trace', async ({ page }) => {
await page.context().tracing.start({ screenshots: true, snapshots: true });
// 测试操作
await page.goto('/');
await page.context().tracing.stop({ path: 'trace.zip' });
});
// 查看:npx playwright show-trace trace.zipConsole Logs
控制台日志
typescript
test('capture console', async ({ page }) => {
page.on('console', msg => console.log(`Browser: ${msg.text()}`));
page.on('pageerror', error => console.error(`Error: ${error.message}`));
await page.goto('/');
});typescript
test('capture console', async ({ page }) => {
page.on('console', msg => console.log(`浏览器: ${msg.text()}`));
page.on('pageerror', error => console.error(`错误: ${error.message}`));
await page.goto('/');
});Best Practices
最佳实践
1. Use Stable Locators
1. 使用稳定的定位器
typescript
// ✅ Good - Role-based, stable
await page.getByRole('button', { name: 'Submit' }).click();
await page.getByLabel('Email').fill('test@example.com');
// ❌ Bad - Fragile, implementation-dependent
await page.click('button.btn-primary.submit-btn');
await page.fill('div > form > input:nth-child(3)');typescript
// ✅ 推荐 - 基于角色,稳定
await page.getByRole('button', { name: 'Submit' }).click();
await page.getByLabel('Email').fill('test@example.com');
// ❌ 不推荐 - 脆弱,依赖实现细节
await page.click('button.btn-primary.submit-btn');
await page.fill('div > form > input:nth-child(3)');2. Leverage Auto-Waiting
2. 利用自动等待
typescript
// ✅ Good - Auto-waits
await page.click('button');
await expect(page.locator('.result')).toBeVisible();
// ❌ Bad - Manual waits
await page.waitForTimeout(2000);
await page.click('button');typescript
// ✅ 推荐 - 自动等待
await page.click('button');
await expect(page.locator('.result')).toBeVisible();
// ❌ 不推荐 - 手动等待
await page.waitForTimeout(2000);
await page.click('button');3. Use Page Object Model
3. 使用页面对象模型
typescript
// ✅ Good - Reusable, maintainable
const loginPage = new LoginPage(page);
await loginPage.login('user', 'pass');
// ❌ Bad - Duplicated selectors
await page.fill('[name="username"]', 'user');
await page.fill('[name="password"]', 'pass');typescript
// ✅ 推荐 - 可复用,易维护
const loginPage = new LoginPage(page);
await loginPage.login('user', 'pass');
// ❌ 不推荐 - 选择器重复
await page.fill('[name="username"]', 'user');
await page.fill('[name="password"]', 'pass');4. Parallel-Safe Tests
4. 并行安全的测试
typescript
// ✅ Good - Isolated
test('user signup', async ({ page }) => {
const uniqueEmail = `user-${Date.now()}@test.com`;
await signUp(page, uniqueEmail);
});
// ❌ Bad - Shared state
test('user signup', async ({ page }) => {
await signUp(page, 'test@test.com'); // Conflicts in parallel
});typescript
// ✅ 推荐 - 隔离性好
test('user signup', async ({ page }) => {
const uniqueEmail = `user-${Date.now()}@test.com`;
await signUp(page, uniqueEmail);
});
// ❌ 不推荐 - 共享状态
test('user signup', async ({ page }) => {
await signUp(page, 'test@test.com'); // 并行执行时会冲突
});5. Handle Flakiness
5. 处理不稳定测试
typescript
// ✅ Good - Wait for network idle
await page.goto('/', { waitUntil: 'networkidle' });
await expect(page.locator('.data')).toBeVisible();
// Configure retries
test.describe(() => {
test.use({ retries: 2 });
test('flaky test', async ({ page }) => {
// Test with auto-retry
});
});typescript
// ✅ 推荐 - 等待网络空闲
await page.goto('/', { waitUntil: 'networkidle' });
await expect(page.locator('.data')).toBeVisible();
// 配置重试
test.describe(() => {
test.use({ retries: 2 });
test('flaky test', async ({ page }) => {
// 自动重试的测试
});
});Common Patterns
常见模式
Multi-Page Scenarios
多页面场景
typescript
test('popup handling', async ({ page, context }) => {
// Listen for new page
const popupPromise = context.waitForEvent('page');
await page.click('a[target="_blank"]');
const popup = await popupPromise;
await popup.waitForLoadState();
await expect(popup).toHaveTitle('New Window');
await popup.close();
});typescript
test('popup handling', async ({ page, context }) => {
// 监听新页面
const popupPromise = context.waitForEvent('page');
await page.click('a[target="_blank"]');
const popup = await popupPromise;
await popup.waitForLoadState();
await expect(popup).toHaveTitle('New Window');
await popup.close();
});Conditional Logic
条件逻辑
typescript
test('handle optional elements', async ({ page }) => {
await page.goto('/');
// Close modal if present
const modal = page.locator('.modal');
if (await modal.isVisible()) {
await page.click('.modal .close-button');
}
// Or use count
const cookieBanner = page.locator('.cookie-banner');
if ((await cookieBanner.count()) > 0) {
await page.click('.accept-cookies');
}
});typescript
test('handle optional elements', async ({ page }) => {
await page.goto('/');
// 如果模态框存在则关闭
const modal = page.locator('.modal');
if (await modal.isVisible()) {
await page.click('.modal .close-button');
}
// 或使用数量判断
const cookieBanner = page.locator('.cookie-banner');
if ((await cookieBanner.count()) > 0) {
await page.click('.accept-cookies');
}
});Data-Driven Tests
数据驱动测试
typescript
const testCases = [
{ input: 'hello', expected: 'HELLO' },
{ input: 'World', expected: 'WORLD' },
{ input: '123', expected: '123' },
];
for (const { input, expected } of testCases) {
test(`transforms "${input}" to "${expected}"`, async ({ page }) => {
await page.goto('/transform');
await page.fill('input', input);
await page.click('button');
await expect(page.locator('.result')).toHaveText(expected);
});
}typescript
const testCases = [
{ input: 'hello', expected: 'HELLO' },
{ input: 'World', expected: 'WORLD' },
{ input: '123', expected: '123' },
];
for (const { input, expected } of testCases) {
test(`transforms "${input}" to "${expected}"`, async ({ page }) => {
await page.goto('/transform');
await page.fill('input', input);
await page.click('button');
await expect(page.locator('.result')).toHaveText(expected);
});
}