playwright-testing
Compare original and translation side by side
🇺🇸
Original
English🇨🇳
Translation
ChinesePlaywright E2E Testing Skill
Playwright 端到端测试技能
Load with: base.md + [framework].md
For end-to-end testing of web applications with Playwright - cross-browser, fast, reliable.
加载方式:base.md + [framework].md
本文介绍如何使用Playwright进行Web应用的端到端测试——跨浏览器、快速、可靠。
Setup
环境搭建
Installation
安装
bash
undefinedbash
undefinedNew project
新项目初始化
npm init playwright@latest
npm init playwright@latest
Existing project
现有项目添加依赖
npm install -D @playwright/test
npx playwright install
undefinednpm install -D @playwright/test
npx playwright install
undefinedConfiguration
配置
typescript
// playwright.config.ts
import { defineConfig, devices } from '@playwright/test';
export default defineConfig({
testDir: './e2e',
fullyParallel: true,
forbidOnly: !!process.env.CI,
retries: process.env.CI ? 2 : 0,
workers: process.env.CI ? 1 : undefined,
reporter: [
['html'],
['list'],
process.env.CI ? ['github'] : ['line'],
],
use: {
baseURL: process.env.BASE_URL || 'http://localhost:3000',
trace: 'on-first-retry',
screenshot: 'only-on-failure',
video: 'retain-on-failure',
},
projects: [
// Auth setup - runs once before all tests
{ name: 'setup', testMatch: /.*\.setup\.ts/ },
{
name: 'chromium',
use: { ...devices['Desktop Chrome'] },
dependencies: ['setup'],
},
{
name: 'firefox',
use: { ...devices['Desktop Firefox'] },
dependencies: ['setup'],
},
{
name: 'webkit',
use: { ...devices['Desktop Safari'] },
dependencies: ['setup'],
},
// Mobile viewports
{
name: 'mobile-chrome',
use: { ...devices['Pixel 5'] },
dependencies: ['setup'],
},
{
name: 'mobile-safari',
use: { ...devices['iPhone 12'] },
dependencies: ['setup'],
},
],
// Start dev server before tests
webServer: {
command: 'npm run dev',
url: 'http://localhost:3000',
reuseExistingServer: !process.env.CI,
timeout: 120 * 1000,
},
});typescript
// playwright.config.ts
import { defineConfig, devices } from '@playwright/test';
export default defineConfig({
testDir: './e2e',
fullyParallel: true,
forbidOnly: !!process.env.CI,
retries: process.env.CI ? 2 : 0,
workers: process.env.CI ? 1 : undefined,
reporter: [
['html'],
['list'],
process.env.CI ? ['github'] : ['line'],
],
use: {
baseURL: process.env.BASE_URL || 'http://localhost:3000',
trace: 'on-first-retry',
screenshot: 'only-on-failure',
video: 'retain-on-failure',
},
projects: [
// 全局认证前置操作 - 所有测试前运行一次
{ name: 'setup', testMatch: /.*\.setup\.ts/ },
{
name: 'chromium',
use: { ...devices['Desktop Chrome'] },
dependencies: ['setup'],
},
{
name: 'firefox',
use: { ...devices['Desktop Firefox'] },
dependencies: ['setup'],
},
{
name: 'webkit',
use: { ...devices['Desktop Safari'] },
dependencies: ['setup'],
},
// 移动端视口
{
name: 'mobile-chrome',
use: { ...devices['Pixel 5'] },
dependencies: ['setup'],
},
{
name: 'mobile-safari',
use: { ...devices['iPhone 12'] },
dependencies: ['setup'],
},
],
// 测试前启动开发服务器
webServer: {
command: 'npm run dev',
url: 'http://localhost:3000',
reuseExistingServer: !process.env.CI,
timeout: 120 * 1000,
},
});Project Structure
项目结构
project/
├── e2e/
│ ├── fixtures/
│ │ ├── auth.fixture.ts # Auth fixtures
│ │ └── test.fixture.ts # Extended test with fixtures
│ ├── pages/
│ │ ├── base.page.ts # Base page object
│ │ ├── login.page.ts # Login page object
│ │ ├── dashboard.page.ts # Dashboard page object
│ │ └── index.ts # Export all pages
│ ├── tests/
│ │ ├── auth.spec.ts # Auth tests
│ │ ├── dashboard.spec.ts # Dashboard tests
│ │ └── checkout.spec.ts # Checkout flow tests
│ ├── utils/
│ │ ├── helpers.ts # Test helpers
│ │ └── test-data.ts # Test data factories
│ └── auth.setup.ts # Global auth setup
├── playwright.config.ts
└── .auth/ # Stored auth state (gitignored)project/
├── e2e/
│ ├── fixtures/
│ │ ├── auth.fixture.ts # 认证夹具
│ │ └── test.fixture.ts # 扩展测试夹具
│ ├── pages/
│ │ ├── base.page.ts # 基础页面对象
│ │ ├── login.page.ts # 登录页面对象
│ │ ├── dashboard.page.ts # 控制台页面对象
│ │ └── index.ts # 导出所有页面对象
│ ├── tests/
│ │ ├── auth.spec.ts # 认证测试
│ │ ├── dashboard.spec.ts # 控制台测试
│ │ └── checkout.spec.ts # 结账流程测试
│ ├── utils/
│ │ ├── helpers.ts # 测试辅助工具
│ │ └── test-data.ts # 测试数据工厂
│ └── auth.setup.ts # 全局认证前置脚本
├── playwright.config.ts
└── .auth/ # 存储认证状态(已加入.gitignore)Locator Strategy (Priority Order)
定位器策略(优先级排序)
Use locators that mirror how users interact with the page:
typescript
// ✅ BEST: Role-based (accessible, resilient)
page.getByRole('button', { name: 'Submit' })
page.getByRole('textbox', { name: 'Email' })
page.getByRole('link', { name: 'Sign up' })
page.getByRole('heading', { name: 'Welcome' })
// ✅ GOOD: User-facing text
page.getByLabel('Email address')
page.getByPlaceholder('Enter your email')
page.getByText('Welcome back')
page.getByTitle('Profile settings')
// ✅ GOOD: Test IDs (stable, explicit)
page.getByTestId('submit-button')
page.getByTestId('user-avatar')
// ⚠️ AVOID: CSS selectors (brittle)
page.locator('.btn-primary')
page.locator('#submit')
// ❌ NEVER: XPath (extremely brittle)
page.locator('//div[@class="container"]/button[1]')应使用与用户操作页面方式一致的定位器:
typescript
// ✅ 最佳选择:基于角色(可访问性强、稳定性高)
page.getByRole('button', { name: '提交' })
page.getByRole('textbox', { name: '邮箱' })
page.getByRole('link', { name: '注册' })
page.getByRole('heading', { name: '欢迎' })
// ✅ 推荐:用户可见文本
page.getByLabel('邮箱地址')
page.getByPlaceholder('请输入邮箱')
page.getByText('欢迎回来')
page.getByTitle('个人设置')
// ✅ 推荐:测试ID(稳定、明确)
page.getByTestId('submit-button')
page.getByTestId('user-avatar')
// ⚠️ 尽量避免:CSS选择器(易失效)
page.locator('.btn-primary')
page.locator('#submit')
// ❌ 绝对禁止:XPath(极易失效)
page.locator('//div[@class="container"]/button[1]')Chaining Locators
定位器链式调用
typescript
// Narrow down to specific section
const form = page.getByRole('form', { name: 'Login' });
await form.getByRole('textbox', { name: 'Email' }).fill('user@example.com');
await form.getByRole('button', { name: 'Submit' }).click();
// Filter within a list
const productCard = page.getByTestId('product-card')
.filter({ hasText: 'Pro Plan' });
await productCard.getByRole('button', { name: 'Buy' }).click();typescript
// 缩小到特定区域
const form = page.getByRole('form', { name: '登录' });
await form.getByRole('textbox', { name: '邮箱' }).fill('user@example.com');
await form.getByRole('button', { name: '提交' }).click();
// 在列表中筛选
const productCard = page.getByTestId('product-card')
.filter({ hasText: '专业版' });
await productCard.getByRole('button', { name: '购买' }).click();Page Object Model
页面对象模型
Base Page
基础页面类
typescript
// e2e/pages/base.page.ts
import { Page, Locator } from '@playwright/test';
export abstract class BasePage {
constructor(protected page: Page) {}
async navigate(path: string = '/') {
await this.page.goto(path);
}
async waitForPageLoad() {
await this.page.waitForLoadState('networkidle');
}
// Common elements
get header() {
return this.page.getByRole('banner');
}
get footer() {
return this.page.getByRole('contentinfo');
}
// Common actions
async clickNavLink(name: string) {
await this.header.getByRole('link', { name }).click();
}
}typescript
// e2e/pages/base.page.ts
import { Page, Locator } from '@playwright/test';
export abstract class BasePage {
constructor(protected page: Page) {}
async navigate(path: string = '/') {
await this.page.goto(path);
}
async waitForPageLoad() {
await this.page.waitForLoadState('networkidle');
}
// 通用元素定位
get header() {
return this.page.getByRole('banner');
}
get footer() {
return this.page.getByRole('contentinfo');
}
// 通用操作
async clickNavLink(name: string) {
await this.header.getByRole('link', { name }).click();
}
}Page Implementation
页面实现类
typescript
// e2e/pages/login.page.ts
import { Page, expect } from '@playwright/test';
import { BasePage } from './base.page';
export class LoginPage extends BasePage {
readonly emailInput: Locator;
readonly passwordInput: Locator;
readonly submitButton: Locator;
readonly errorMessage: Locator;
constructor(page: Page) {
super(page);
this.emailInput = page.getByLabel('Email');
this.passwordInput = page.getByLabel('Password');
this.submitButton = page.getByRole('button', { name: 'Sign in' });
this.errorMessage = page.getByRole('alert');
}
async goto() {
await this.navigate('/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);
}
async expectLoggedIn() {
await expect(this.page).toHaveURL(/.*dashboard/);
}
}typescript
// e2e/pages/dashboard.page.ts
import { Page, Locator, expect } from '@playwright/test';
import { BasePage } from './base.page';
export class DashboardPage extends BasePage {
readonly welcomeHeading: Locator;
readonly userMenu: Locator;
readonly logoutButton: Locator;
constructor(page: Page) {
super(page);
this.welcomeHeading = page.getByRole('heading', { name: /welcome/i });
this.userMenu = page.getByTestId('user-menu');
this.logoutButton = page.getByRole('button', { name: 'Logout' });
}
async goto() {
await this.navigate('/dashboard');
}
async logout() {
await this.userMenu.click();
await this.logoutButton.click();
}
async expectWelcome(name: string) {
await expect(this.welcomeHeading).toContainText(name);
}
}typescript
// e2e/pages/login.page.ts
import { Page, expect } from '@playwright/test';
import { BasePage } from './base.page';
export class LoginPage extends BasePage {
readonly emailInput: Locator;
readonly passwordInput: Locator;
readonly submitButton: Locator;
readonly errorMessage: Locator;
constructor(page: Page) {
super(page);
this.emailInput = page.getByLabel('邮箱');
this.passwordInput = page.getByLabel('密码');
this.submitButton = page.getByRole('button', { name: '登录' });
this.errorMessage = page.getByRole('alert');
}
async goto() {
await this.navigate('/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);
}
async expectLoggedIn() {
await expect(this.page).toHaveURL(/.*dashboard/);
}
}typescript
// e2e/pages/dashboard.page.ts
import { Page, Locator, expect } from '@playwright/test';
import { BasePage } from './base.page';
export class DashboardPage extends BasePage {
readonly welcomeHeading: Locator;
readonly userMenu: Locator;
readonly logoutButton: Locator;
constructor(page: Page) {
super(page);
this.welcomeHeading = page.getByRole('heading', { name: /welcome/i });
this.userMenu = page.getByTestId('user-menu');
this.logoutButton = page.getByRole('button', { name: '退出登录' });
}
async goto() {
await this.navigate('/dashboard');
}
async logout() {
await this.userMenu.click();
await this.logoutButton.click();
}
async expectWelcome(name: string) {
await expect(this.welcomeHeading).toContainText(name);
}
}Export All Pages
导出所有页面对象
typescript
// e2e/pages/index.ts
export { BasePage } from './base.page';
export { LoginPage } from './login.page';
export { DashboardPage } from './dashboard.page';typescript
// e2e/pages/index.ts
export { BasePage } from './base.page';
export { LoginPage } from './login.page';
export { DashboardPage } from './dashboard.page';Authentication
认证处理
Global Auth Setup
全局认证前置脚本
typescript
// e2e/auth.setup.ts
import { test as setup, expect } from '@playwright/test';
import path from 'path';
const authFile = path.join(__dirname, '../.auth/user.json');
setup('authenticate', async ({ page }) => {
// Go to login page
await page.goto('/login');
// Login with test credentials
await page.getByLabel('Email').fill(process.env.TEST_USER_EMAIL!);
await page.getByLabel('Password').fill(process.env.TEST_USER_PASSWORD!);
await page.getByRole('button', { name: 'Sign in' }).click();
// Wait for auth to complete
await expect(page).toHaveURL(/.*dashboard/);
// Save auth state for reuse
await page.context().storageState({ path: authFile });
});typescript
// e2e/auth.setup.ts
import { test as setup, expect } 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(process.env.TEST_USER_EMAIL!);
await page.getByLabel('密码').fill(process.env.TEST_USER_PASSWORD!);
await page.getByRole('button', { name: '登录' }).click();
// 等待认证完成
await expect(page).toHaveURL(/.*dashboard/);
// 保存认证状态供后续测试复用
await page.context().storageState({ path: authFile });
});Using Auth in Tests
在测试中复用认证状态
typescript
// playwright.config.ts
export default defineConfig({
projects: [
{ name: 'setup', testMatch: /.*\.setup\.ts/ },
{
name: 'chromium',
use: {
...devices['Desktop Chrome'],
storageState: '.auth/user.json',
},
dependencies: ['setup'],
},
],
});typescript
// playwright.config.ts
export default defineConfig({
projects: [
{ name: 'setup', testMatch: /.*\.setup\.ts/ },
{
name: 'chromium',
use: {
...devices['Desktop Chrome'],
storageState: '.auth/user.json',
},
dependencies: ['setup'],
},
],
});Tests Without Auth
无需认证的测试
typescript
// e2e/tests/public.spec.ts
import { test } from '@playwright/test';
// Override to skip auth
test.use({ storageState: { cookies: [], origins: [] } });
test('homepage loads for anonymous users', async ({ page }) => {
await page.goto('/');
await expect(page.getByRole('heading', { name: 'Welcome' })).toBeVisible();
});typescript
// e2e/tests/public.spec.ts
import { test } from '@playwright/test';
// 覆盖配置,跳过认证状态
test.use({ storageState: { cookies: [], origins: [] } });
test('匿名用户可正常访问首页', async ({ page }) => {
await page.goto('/');
await expect(page.getByRole('heading', { name: '欢迎' })).toBeVisible();
});Writing Tests
编写测试用例
Basic Test Structure
基础测试结构
typescript
// e2e/tests/auth.spec.ts
import { test, expect } from '@playwright/test';
import { LoginPage } from '../pages';
test.describe('Authentication', () => {
test.beforeEach(async ({ page }) => {
// Skip stored auth for login tests
await page.context().clearCookies();
});
test('successful login redirects to dashboard', async ({ page }) => {
const loginPage = new LoginPage(page);
await loginPage.goto();
await loginPage.login('user@example.com', 'password123');
await loginPage.expectLoggedIn();
});
test('invalid credentials show error', async ({ page }) => {
const loginPage = new LoginPage(page);
await loginPage.goto();
await loginPage.login('wrong@example.com', 'wrongpass');
await loginPage.expectError('Invalid email or password');
});
test('empty form shows validation errors', async ({ page }) => {
const loginPage = new LoginPage(page);
await loginPage.goto();
await loginPage.submitButton.click();
await expect(page.getByText('Email is required')).toBeVisible();
await expect(page.getByText('Password is required')).toBeVisible();
});
});typescript
// e2e/tests/auth.spec.ts
import { test, expect } from '@playwright/test';
import { LoginPage } from '../pages';
test.describe('认证功能', () => {
test.beforeEach(async ({ page }) => {
// 登录测试需清除已保存的认证状态
await page.context().clearCookies();
});
test('登录成功后跳转到控制台', async ({ page }) => {
const loginPage = new LoginPage(page);
await loginPage.goto();
await loginPage.login('user@example.com', 'password123');
await loginPage.expectLoggedIn();
});
test('无效凭证会显示错误提示', async ({ page }) => {
const loginPage = new LoginPage(page);
await loginPage.goto();
await loginPage.login('wrong@example.com', 'wrongpass');
await loginPage.expectError('邮箱或密码无效');
});
test('空表单提交会显示验证错误', async ({ page }) => {
const loginPage = new LoginPage(page);
await loginPage.goto();
await loginPage.submitButton.click();
await expect(page.getByText('邮箱为必填项')).toBeVisible();
await expect(page.getByText('密码为必填项')).toBeVisible();
});
});User Flow Tests
用户流程测试
typescript
// e2e/tests/checkout.spec.ts
import { test, expect } from '@playwright/test';
test.describe('Checkout Flow', () => {
test('complete purchase flow', async ({ page }) => {
// 1. Browse products
await page.goto('/products');
await page.getByTestId('product-card')
.filter({ hasText: 'Pro Plan' })
.getByRole('button', { name: 'Add to cart' })
.click();
// 2. View cart
await page.getByRole('link', { name: 'Cart' }).click();
await expect(page.getByText('Pro Plan')).toBeVisible();
await expect(page.getByTestId('cart-total')).toContainText('$29.99');
// 3. Checkout
await page.getByRole('button', { name: 'Checkout' }).click();
// 4. Fill payment (use Stripe test card)
const stripeFrame = page.frameLocator('iframe[name*="stripe"]');
await stripeFrame.getByPlaceholder('Card number').fill('4242424242424242');
await stripeFrame.getByPlaceholder('MM / YY').fill('12/30');
await stripeFrame.getByPlaceholder('CVC').fill('123');
// 5. Complete purchase
await page.getByRole('button', { name: 'Pay now' }).click();
// 6. Verify success
await expect(page).toHaveURL(/.*success/);
await expect(page.getByRole('heading', { name: 'Thank you' })).toBeVisible();
});
});typescript
// e2e/tests/checkout.spec.ts
import { test, expect } from '@playwright/test';
test.describe('结账流程', () => {
test('完成完整购买流程', async ({ page }) => {
// 1. 浏览商品
await page.goto('/products');
await page.getByTestId('product-card')
.filter({ hasText: '专业版套餐' })
.getByRole('button', { name: '加入购物车' })
.click();
// 2. 查看购物车
await page.getByRole('link', { name: '购物车' }).click();
await expect(page.getByText('专业版套餐')).toBeVisible();
await expect(page.getByTestId('cart-total')).toContainText('$29.99');
// 3. 进入结账页
await page.getByRole('button', { name: '结账' }).click();
// 4. 填写支付信息(使用Stripe测试卡号)
const stripeFrame = page.frameLocator('iframe[name*="stripe"]');
await stripeFrame.getByPlaceholder('卡号').fill('4242424242424242');
await stripeFrame.getByPlaceholder('MM / YY').fill('12/30');
await stripeFrame.getByPlaceholder('CVC').fill('123');
// 5. 完成支付
await page.getByRole('button', { name: '立即支付' }).click();
// 6. 验证支付成功
await expect(page).toHaveURL(/.*success/);
await expect(page.getByRole('heading', { name: '感谢您的购买' })).toBeVisible();
});
});Assertions
断言
Web-First Assertions (Auto-Wait)
优先使用Web原生断言(自动等待)
typescript
// ✅ These wait and retry automatically
await expect(page.getByRole('button')).toBeVisible();
await expect(page.getByRole('button')).toBeEnabled();
await expect(page.getByRole('button')).toHaveText('Submit');
await expect(page).toHaveURL('/dashboard');
await expect(page).toHaveTitle(/Dashboard/);
// ❌ Avoid manual waits
await page.waitForTimeout(3000); // NEVER do thistypescript
// ✅ 这些断言会自动等待并重试
await expect(page.getByRole('button')).toBeVisible();
await expect(page.getByRole('button')).toBeEnabled();
await expect(page.getByRole('button')).toHaveText('提交');
await expect(page).toHaveURL('/dashboard');
await expect(page).toHaveTitle(/控制台/);
// ❌ 避免手动等待
await page.waitForTimeout(3000); // 绝对不要这样做Soft Assertions
软断言
typescript
// Continue test even if assertion fails
await expect.soft(page.getByTestId('price')).toHaveText('$29.99');
await expect.soft(page.getByTestId('stock')).toHaveText('In Stock');
// Fail at end if any soft assertions failedtypescript
// 即使断言失败,测试仍会继续执行
await expect.soft(page.getByTestId('price')).toHaveText('$29.99');
await expect.soft(page.getByTestId('stock')).toHaveText('有货');
// 所有断言执行完成后,若存在失败则标记测试失败Common Assertions
常用断言
typescript
// Visibility
await expect(locator).toBeVisible();
await expect(locator).toBeHidden();
await expect(locator).toBeAttached();
// Text content
await expect(locator).toHaveText('exact text');
await expect(locator).toContainText('partial');
await expect(locator).toHaveValue('input value');
// State
await expect(locator).toBeEnabled();
await expect(locator).toBeDisabled();
await expect(locator).toBeChecked();
await expect(locator).toBeFocused();
// Count
await expect(locator).toHaveCount(5);
// Page
await expect(page).toHaveURL('/dashboard');
await expect(page).toHaveTitle('Dashboard | App');
await expect(page).toHaveScreenshot('dashboard.png');typescript
// 可见性
await expect(locator).toBeVisible();
await expect(locator).toBeHidden();
await expect(locator).toBeAttached();
// 文本内容
await expect(locator).toHaveText('精确文本');
await expect(locator).toContainText('部分文本');
await expect(locator).toHaveValue('输入框值');
// 元素状态
await expect(locator).toBeEnabled();
await expect(locator).toBeDisabled();
await expect(locator).toBeChecked();
await expect(locator).toBeFocused();
// 数量
await expect(locator).toHaveCount(5);
// 页面状态
await expect(page).toHaveURL('/dashboard');
await expect(page).toHaveTitle('控制台 | 应用');
await expect(page).toHaveScreenshot('dashboard.png');Mocking & Network
模拟与网络控制
Mock API Responses
模拟API响应
typescript
test('shows error when API fails', async ({ page }) => {
// Mock API to return error
await page.route('**/api/users', (route) => {
route.fulfill({
status: 500,
body: JSON.stringify({ error: 'Server error' }),
});
});
await page.goto('/users');
await expect(page.getByText('Failed to load users')).toBeVisible();
});
test('displays user data from API', async ({ page }) => {
// Mock successful response
await page.route('**/api/users', (route) => {
route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify([
{ id: 1, name: 'John Doe', email: 'john@example.com' },
{ id: 2, name: 'Jane Doe', email: 'jane@example.com' },
]),
});
});
await page.goto('/users');
await expect(page.getByText('John Doe')).toBeVisible();
await expect(page.getByText('Jane Doe')).toBeVisible();
});typescript
test('API失败时显示错误提示', async ({ page }) => {
// 模拟API返回错误
await page.route('**/api/users', (route) => {
route.fulfill({
status: 500,
body: JSON.stringify({ error: '服务器错误' }),
});
});
await page.goto('/users');
await expect(page.getByText('加载用户失败')).toBeVisible();
});
test('显示API返回的用户数据', async ({ page }) => {
// 模拟API返回成功响应
await page.route('**/api/users', (route) => {
route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify([
{ id: 1, name: 'John Doe', email: 'john@example.com' },
{ id: 2, name: 'Jane Doe', email: 'jane@example.com' },
]),
});
});
await page.goto('/users');
await expect(page.getByText('John Doe')).toBeVisible();
await expect(page.getByText('Jane Doe')).toBeVisible();
});Wait for API Calls
等待API调用完成
typescript
test('submits form and shows success', async ({ page }) => {
await page.goto('/contact');
// Fill form
await page.getByLabel('Name').fill('John');
await page.getByLabel('Email').fill('john@example.com');
await page.getByLabel('Message').fill('Hello!');
// Wait for API call on submit
const responsePromise = page.waitForResponse('**/api/contact');
await page.getByRole('button', { name: 'Send' }).click();
const response = await responsePromise;
expect(response.status()).toBe(200);
await expect(page.getByText('Message sent!')).toBeVisible();
});typescript
test('提交表单后显示成功提示', async ({ page }) => {
await page.goto('/contact');
// 填写表单
await page.getByLabel('姓名').fill('John');
await page.getByLabel('邮箱').fill('john@example.com');
await page.getByLabel('留言').fill('你好!');
// 监听API调用
const responsePromise = page.waitForResponse('**/api/contact');
await page.getByRole('button', { name: '发送' }).click();
const response = await responsePromise;
expect(response.status()).toBe(200);
await expect(page.getByText('留言已发送!')).toBeVisible();
});Visual Testing
可视化测试
typescript
// Full page screenshot
await expect(page).toHaveScreenshot('homepage.png');
// Element screenshot
await expect(page.getByTestId('chart')).toHaveScreenshot('chart.png');
// With options
await expect(page).toHaveScreenshot('dashboard.png', {
maxDiffPixels: 100,
mask: [page.getByTestId('timestamp')], // Ignore dynamic content
});typescript
// 整页截图对比
await expect(page).toHaveScreenshot('homepage.png');
// 元素截图对比
await expect(page.getByTestId('chart')).toHaveScreenshot('chart.png');
// 自定义选项
await expect(page).toHaveScreenshot('dashboard.png', {
maxDiffPixels: 100,
mask: [page.getByTestId('timestamp')], // 忽略动态内容
});CI/CD Integration
CI/CD集成
GitHub Actions
GitHub Actions配置
yaml
undefinedyaml
undefined.github/workflows/e2e.yml
.github/workflows/e2e.yml
name: E2E Tests
on:
push:
branches: [main]
pull_request:
branches: [main]
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 20
cache: 'npm'
- 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 --project=chromium
env:
BASE_URL: ${{ secrets.STAGING_URL }}
TEST_USER_EMAIL: ${{ secrets.TEST_USER_EMAIL }}
TEST_USER_PASSWORD: ${{ secrets.TEST_USER_PASSWORD }}
- uses: actions/upload-artifact@v4
if: failure()
with:
name: playwright-report
path: playwright-report/
retention-days: 7undefinedname: 端到端测试
on:
push:
branches: [main]
pull_request:
branches: [main]
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 20
cache: 'npm'
- name: 安装依赖
run: npm ci
- name: 安装Playwright浏览器
run: npx playwright install --with-deps chromium
- name: 运行端到端测试
run: npx playwright test --project=chromium
env:
BASE_URL: ${{ secrets.STAGING_URL }}
TEST_USER_EMAIL: ${{ secrets.TEST_USER_EMAIL }}
TEST_USER_PASSWORD: ${{ secrets.TEST_USER_PASSWORD }}
- uses: actions/upload-artifact@v4
if: failure()
with:
name: playwright-report
path: playwright-report/
retention-days: 7undefinedRun Specific Tests
运行指定测试
bash
undefinedbash
undefinedRun all tests
运行所有测试
npx playwright test
npx playwright test
Run specific file
运行指定测试文件
npx playwright test e2e/tests/auth.spec.ts
npx playwright test e2e/tests/auth.spec.ts
Run tests with tag
运行带有指定标签的测试
npx playwright test --grep @critical
npx playwright test --grep @critical
Run in headed mode (debug)
以有界面模式运行(调试用)
npx playwright test --headed
npx playwright test --headed
Run specific browser
指定浏览器运行
npx playwright test --project=chromium
npx playwright test --project=chromium
Debug mode
调试模式
npx playwright test --debug
npx playwright test --debug
Show HTML report
查看HTML测试报告
npx playwright show-report
---npx playwright show-report
---Test Data
测试数据
Factories
数据工厂
typescript
// e2e/utils/test-data.ts
import { faker } from '@faker-js/faker';
export const createUser = (overrides = {}) => ({
email: faker.internet.email(),
password: faker.internet.password({ length: 12 }),
name: faker.person.fullName(),
...overrides,
});
export const createProduct = (overrides = {}) => ({
name: faker.commerce.productName(),
price: faker.commerce.price({ min: 10, max: 100 }),
description: faker.commerce.productDescription(),
...overrides,
});typescript
// e2e/utils/test-data.ts
import { faker } from '@faker-js/faker';
export const createUser = (overrides = {}) => ({
email: faker.internet.email(),
password: faker.internet.password({ length: 12 }),
name: faker.person.fullName(),
...overrides,
});
export const createProduct = (overrides = {}) => ({
name: faker.commerce.productName(),
price: faker.commerce.price({ min: 10, max: 100 }),
description: faker.commerce.productDescription(),
...overrides,
});Environment Variables
环境变量
bash
undefinedbash
undefined.env.test
.env.test
BASE_URL=http://localhost:3000
TEST_USER_EMAIL=test@example.com
TEST_USER_PASSWORD=testpassword123
---BASE_URL=http://localhost:3000
TEST_USER_EMAIL=test@example.com
TEST_USER_PASSWORD=testpassword123
---Debugging
调试
Trace Viewer
追踪查看器
typescript
// Enable in config for failures
use: {
trace: 'on-first-retry',
}
// View traces
npx playwright show-trace trace.ziptypescript
// 在配置中开启失败重试时的追踪记录
use: {
trace: 'on-first-retry',
}
// 查看追踪记录
npx playwright show-trace trace.zipDebug Mode
调试模式
bash
undefinedbash
undefinedStep through test
逐步执行测试
npx playwright test --debug
npx playwright test --debug
Pause at specific point
在测试代码中添加暂停
await page.pause(); // In test code
undefinedawait page.pause(); // 测试运行到此处会暂停
undefinedVS Code Extension
VS Code扩展
Install "Playwright Test for VS Code" for:
- Run tests from editor
- Debug with breakpoints
- Pick locators visually
- Watch mode
安装“Playwright Test for VS Code”扩展,可实现:
- 在编辑器中直接运行测试
- 断点调试
- 可视化选择定位器
- 监听模式
Dead Link Detection (REQUIRED)
反模式
Every project MUST include dead link detection tests. Run these on every deployment.
- 硬编码等待 - 应使用自动等待的断言替代
- CSS/XPath选择器 - 应使用角色/文本/测试ID定位器
- 测试第三方网站 - 应模拟外部依赖
- 测试间共享状态 - 每个测试必须独立隔离
- 遗漏await - 启用ESLint规则
no-floating-promises - 基于时间的不稳定测试 - 模拟日期/时间
- 测试实现细节 - 应测试用户可见的行为
- 超大测试文件 - 按功能/页面拆分测试
Link Validator Test
快速参考
typescript
// e2e/tests/links.spec.ts
import { test, expect } from '@playwright/test';
const PAGES_TO_CHECK = ['/', '/about', '/pricing', '/blog', '/contact'];
test.describe('Dead Link Detection', () => {
for (const pagePath of PAGES_TO_CHECK) {
test(`no dead links on ${pagePath}`, async ({ page, request }) => {
await page.goto(pagePath);
// Get all links on the page
const links = await page.locator('a[href]').all();
const hrefs = await Promise.all(
links.map(link => link.getAttribute('href'))
);
// Filter to internal and absolute external links
const uniqueLinks = [...new Set(hrefs.filter(Boolean))] as string[];
for (const href of uniqueLinks) {
// Skip mailto, tel, and anchor links
if (href.startsWith('mailto:') || href.startsWith('tel:') || href.startsWith('#')) {
continue;
}
// Build full URL
const url = href.startsWith('http') ? href : new URL(href, page.url()).href;
// Check link status
const response = await request.get(url, {
timeout: 10000,
ignoreHTTPSErrors: true,
});
expect(
response.ok(),
`Dead link found on ${pagePath}: ${href} returned ${response.status()}`
).toBeTruthy();
}
});
}
});bash
undefinedComprehensive Link Crawler
初始化
typescript
// e2e/tests/site-links.spec.ts
import { test, expect, Page, APIRequestContext } from '@playwright/test';
interface LinkResult {
url: string;
status: number;
foundOn: string;
}
async function checkAllLinks(
page: Page,
request: APIRequestContext,
startUrl: string
): Promise<LinkResult[]> {
const visited = new Set<string>();
const results: LinkResult[] = [];
const toVisit = [startUrl];
const baseUrl = new URL(startUrl).origin;
while (toVisit.length > 0) {
const currentUrl = toVisit.pop()!;
if (visited.has(currentUrl)) continue;
visited.add(currentUrl);
try {
await page.goto(currentUrl);
const links = await page.locator('a[href]').all();
for (const link of links) {
const href = await link.getAttribute('href');
if (!href || href.startsWith('#') || href.startsWith('mailto:') || href.startsWith('tel:')) {
continue;
}
const fullUrl = href.startsWith('http') ? href : new URL(href, currentUrl).href;
// Check link
const response = await request.get(fullUrl, {
timeout: 10000,
ignoreHTTPSErrors: true,
});
results.push({
url: fullUrl,
status: response.status(),
foundOn: currentUrl,
});
// Add internal links to queue
if (fullUrl.startsWith(baseUrl) && !visited.has(fullUrl)) {
toVisit.push(fullUrl);
}
}
} catch (error) {
results.push({
url: currentUrl,
status: 0,
foundOn: 'navigation',
});
}
}
return results;
}
test('no dead links on entire site', async ({ page, request, baseURL }) => {
const results = await checkAllLinks(page, request, baseURL!);
const deadLinks = results.filter(r => r.status >= 400 || r.status === 0);
if (deadLinks.length > 0) {
console.error('Dead links found:');
deadLinks.forEach(link => {
console.error(` ${link.url} (${link.status}) - found on ${link.foundOn}`);
});
}
expect(deadLinks, `Found ${deadLinks.length} dead links`).toHaveLength(0);
});npm init playwright@latest
Image Link Validation
运行测试
typescript
// e2e/tests/images.spec.ts
import { test, expect } from '@playwright/test';
test('no broken images on homepage', async ({ page, request }) => {
await page.goto('/');
const images = await page.locator('img[src]').all();
for (const img of images) {
const src = await img.getAttribute('src');
if (!src) continue;
const url = src.startsWith('http') ? src : new URL(src, page.url()).href;
// Skip data URLs
if (url.startsWith('data:')) continue;
const response = await request.get(url);
expect(
response.ok(),
`Broken image: ${src}`
).toBeTruthy();
// Verify it's actually an image
const contentType = response.headers()['content-type'];
expect(
contentType?.startsWith('image/'),
`${src} is not an image (${contentType})`
).toBeTruthy();
}
});npx playwright test
npx playwright test --headed
npx playwright test --project=chromium
npx playwright test --grep @smoke
CI Integration for Link Checking
调试
yaml
undefinednpx playwright test --debug
npx playwright show-report
npx playwright show-trace trace.zip
.github/workflows/link-check.yml
生成测试代码
name: Link Check
on:
schedule:
- cron: '0 6 * * 1' # Weekly on Monday
push:
branches: [main]
jobs:
link-check:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 20
- run: npm ci
- run: npx playwright install chromium
- run: npx playwright test e2e/tests/links.spec.ts --project=chromium
env:
BASE_URL: ${{ secrets.PRODUCTION_URL }}
---npx playwright codegen localhost:3000
undefinedAnti-Patterns
package.json脚本配置
- Hardcoded waits - Use auto-waiting assertions instead
- CSS/XPath selectors - Use role/text/testid locators
- Testing third-party sites - Mock external dependencies
- Shared state between tests - Each test must be isolated
- Missing awaits - Use ESLint rule
no-floating-promises - Flaky time-based tests - Mock dates/times
- Testing implementation details - Test user-visible behavior
- Huge test files - Split by feature/page
json
{
"scripts": {
"test:e2e": "playwright test",
"test:e2e:headed": "playwright test --headed",
"test:e2e:debug": "playwright test --debug",
"test:e2e:report": "playwright show-report",
"test:e2e:codegen": "playwright codegen"
}
}Quick Reference
—
bash
undefined—
Install
—
npm init playwright@latest
—
Run tests
—
npx playwright test
npx playwright test --headed
npx playwright test --project=chromium
npx playwright test --grep @smoke
—
Debug
—
npx playwright test --debug
npx playwright show-report
npx playwright show-trace trace.zip
—
Generate tests
—
npx playwright codegen localhost:3000
undefined—
Package.json Scripts
—
json
{
"scripts": {
"test:e2e": "playwright test",
"test:e2e:headed": "playwright test --headed",
"test:e2e:debug": "playwright test --debug",
"test:e2e:report": "playwright show-report",
"test:e2e:codegen": "playwright codegen"
}
}—