端到端测试
端到端测试(End-to-End Testing,简称 E2E 测试)是从用户角度验证完整应用流程的测试方法。它模拟真实用户行为,验证从前端到后端再到数据库的整个系统链路。本文将介绍 E2E 测试的核心概念、主流工具实践和最佳实践。
E2E 测试概念
什么是 E2E 测试
E2E 测试验证完整的应用流程,确保系统各组件协同工作正常:
typescript
interface E2ETestCharacteristics {
userPerspective: boolean // 从用户视角测试
fullStack: boolean // 覆盖前后端全链路
realEnvironment: boolean // 接近真实环境
criticalPaths: boolean // 聚焦关键业务流程
highConfidence: boolean // 提供最高信心度
}1
2
3
4
5
6
7
2
3
4
5
6
7
E2E 测试的价值
text
┌─────────────────────────────────────────────────────────────┐
│ 测试信心金字塔 │
├─────────────────────────────────────────────────────────────┤
│ │
│ /\ │
│ /E2E\ ← 最高信心度 │
│ /────\ 验证真实用户体验 │
│ / \ │
│ /Integration\ ← 中等信心度 │
│ /────────────\ 验证组件协作 │
│ / \ │
│ / Unit Tests \ ← 基础信心度 │
│ /──────────────────\ 验证代码逻辑 │
│ │
└─────────────────────────────────────────────────────────────┘1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
2
3
4
5
6
7
8
9
10
11
12
13
14
15
E2E 测试场景
typescript
// 适合 E2E 测试的场景
const e2eTestScenarios = {
// 核心业务流程
criticalPaths: [
'用户注册和登录流程',
'购物车和结账流程',
'订单创建和支付流程',
'用户设置和配置'
],
// 跨系统集成
crossSystemIntegration: [
'第三方支付集成',
'OAuth 登录流程',
'API 集成验证'
],
// 关键用户旅程
keyUserJourneys: [
'新用户引导流程',
'产品搜索和筛选',
'内容创建和发布'
]
}1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
Playwright 实践
安装配置
bash
# 安装 Playwright
npm install -D @playwright/test
# 安装浏览器
npx playwright install1
2
3
4
5
2
3
4
5
typescript
// playwright.config.ts
import { defineConfig, devices } from '@playwright/test'
export default defineConfig({
// 测试目录
testDir: './e2e',
// 并行执行
fullyParallel: true,
// CI 上禁止 test.only
forbidOnly: !!process.env.CI,
// CI 上重试失败用例
retries: process.env.CI ? 2 : 0,
// CI 上限制并行
workers: process.env.CI ? 1 : undefined,
// Reporter 配置
reporter: [
['html'],
['junit', { outputFile: 'test-results/junit.xml' }]
],
// 共享配置
use: {
// 基础 URL
baseURL: 'http://localhost:3000',
// 收集失败用例的 trace
trace: 'on-first-retry',
// 截图
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'] }
},
{
name: 'Mobile Safari',
use: { ...devices['iPhone 12'] }
}
],
// 启动开发服务器
webServer: {
command: 'npm run dev',
url: 'http://localhost:3000',
reuseExistingServer: !process.env.CI
}
})1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
基础测试示例
typescript
// e2e/auth.spec.ts
import { test, expect } from '@playwright/test'
test.describe('用户认证', () => {
test('应该成功登录', async ({ page }) => {
// 导航到登录页
await page.goto('/login')
// 填写表单
await page.fill('[name="email"]', 'test@example.com')
await page.fill('[name="password"]', 'password123')
// 点击登录按钮
await page.click('button[type="submit"]')
// 等待导航完成
await page.waitForURL('/dashboard')
// 验证登录成功
await expect(page.locator('.user-avatar')).toBeVisible()
await expect(page.locator('.welcome-message')).toContainText('欢迎')
})
test('应该显示登录错误', async ({ page }) => {
await page.goto('/login')
await page.fill('[name="email"]', 'test@example.com')
await page.fill('[name="password"]', 'wrongpassword')
await page.click('button[type="submit"]')
// 验证错误消息
await expect(page.locator('.error-message')).toBeVisible()
await expect(page.locator('.error-message')).toContainText('密码错误')
})
test('应该成功注册新用户', async ({ page }) => {
await page.goto('/register')
await page.fill('[name="name"]', 'New User')
await page.fill('[name="email"]', `user-${Date.now()}@example.com`)
await page.fill('[name="password"]', 'SecurePass123!')
await page.fill('[name="confirmPassword"]', 'SecurePass123!')
await page.click('button[type="submit"]')
// 验证注册成功并跳转到验证页面
await page.waitForURL('/verify-email')
await expect(page.locator('.success-message')).toContainText('验证邮件已发送')
})
})1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
页面对象模式
typescript
// e2e/pages/LoginPage.ts
import { Page, Locator, expect } 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.locator('[name="email"]')
this.passwordInput = page.locator('[name="password"]')
this.submitButton = page.locator('button[type="submit"]')
this.errorMessage = page.locator('.error-message')
}
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)
}
}
// e2e/pages/DashboardPage.ts
export class DashboardPage {
readonly page: Page
readonly welcomeMessage: Locator
readonly userAvatar: Locator
constructor(page: Page) {
this.page = page
this.welcomeMessage = page.locator('.welcome-message')
this.userAvatar = page.locator('.user-avatar')
}
async expectLoggedIn() {
await expect(this.userAvatar).toBeVisible()
}
}1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
typescript
// e2e/auth.spec.ts (使用页面对象)
import { test, expect } from '@playwright/test'
import { LoginPage } from './pages/LoginPage'
import { DashboardPage } from './pages/DashboardPage'
test.describe('用户认证', () => {
let loginPage: LoginPage
let dashboardPage: DashboardPage
test.beforeEach(async ({ page }) => {
loginPage = new LoginPage(page)
dashboardPage = new DashboardPage(page)
})
test('应该成功登录', async ({ page }) => {
await loginPage.goto()
await loginPage.login('test@example.com', 'password123')
await page.waitForURL('/dashboard')
await dashboardPage.expectLoggedIn()
})
})1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
测试夹具
typescript
// e2e/fixtures.ts
import { test as base, Page } from '@playwright/test'
// 定义夹具类型
type MyFixtures = {
loggedInPage: Page
adminPage: Page
}
// 扩展 test 对象
export const test = base.extend<MyFixtures>({
// 已登录用户页面
loggedInPage: async ({ page }, use) => {
// 设置:登录
await page.goto('/login')
await page.fill('[name="email"]', 'user@example.com')
await page.fill('[name="password"]', 'password123')
await page.click('button[type="submit"]')
await page.waitForURL('/dashboard')
// 使用页面
await use(page)
// 清理(可选)
},
// 管理员页面
adminPage: async ({ page }, use) => {
await page.goto('/login')
await page.fill('[name="email"]', 'admin@example.com')
await page.fill('[name="password"]', 'adminpassword')
await page.click('button[type="submit"]')
await page.waitForURL('/admin')
await use(page)
}
})
// 使用夹具
test('应该显示用户仪表板', async ({ loggedInPage }) => {
await expect(loggedInPage.locator('.dashboard')).toBeVisible()
})
test('应该显示管理面板', async ({ adminPage }) => {
await expect(adminPage.locator('.admin-panel')).toBeVisible()
})1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
API 测试
typescript
// e2e/api.spec.ts
import { test, expect } from '@playwright/test'
test.describe('API 测试', () => {
let authToken: string
test('应该获取认证令牌', async ({ request }) => {
const response = await request.post('/api/auth/login', {
data: {
email: 'test@example.com',
password: 'password123'
}
})
expect(response.ok()).toBeTruthy()
const data = await response.json()
expect(data.token).toBeDefined()
authToken = data.token
})
test('应该获取用户列表', async ({ request }) => {
const response = await request.get('/api/users', {
headers: {
Authorization: `Bearer ${authToken}`
}
})
expect(response.ok()).toBeTruthy()
const users = await response.json()
expect(Array.isArray(users)).toBe(true)
})
test('应该创建新用户', async ({ request }) => {
const response = await request.post('/api/users', {
headers: {
Authorization: `Bearer ${authToken}`
},
data: {
name: 'New User',
email: `user-${Date.now()}@example.com`
}
})
expect(response.status()).toBe(201)
const user = await response.json()
expect(user.id).toBeDefined()
expect(user.name).toBe('New User')
})
})1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
视觉回归测试
typescript
// e2e/visual.spec.ts
import { test, expect } from '@playwright/test'
test.describe('视觉回归测试', () => {
test('首页应该匹配快照', async ({ page }) => {
await page.goto('/')
// 全页面快照
await expect(page).toHaveScreenshot('homepage.png')
})
test('组件应该匹配快照', async ({ page }) => {
await page.goto('/components/button')
const button = page.locator('.btn-primary')
// 组件快照
await expect(button).toHaveScreenshot('primary-button.png')
})
test('响应式布局快照', async ({ page }) => {
await page.goto('/')
// 桌面视图
await page.setViewportSize({ width: 1280, height: 720 })
await expect(page).toHaveScreenshot('homepage-desktop.png')
// 平板视图
await page.setViewportSize({ width: 768, height: 1024 })
await expect(page).toHaveScreenshot('homepage-tablet.png')
// 移动视图
await page.setViewportSize({ width: 375, height: 667 })
await expect(page).toHaveScreenshot('homepage-mobile.png')
})
test('快照对比选项', async ({ page }) => {
await page.goto('/')
// 允许像素差异
await expect(page).toHaveScreenshot('homepage.png', {
maxDiffPixels: 100,
maxDiffPixelRatio: 0.01
})
// 忽略特定区域
await expect(page).toHaveScreenshot('homepage-no-ads.png', {
mask: [page.locator('.ad-banner')]
})
})
})1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
Cypress 实践
Cypress 安装配置
bash
# 安装 Cypress
npm install -D cypress1
2
2
typescript
// cypress.config.ts
import { defineConfig } from 'cypress'
export default defineConfig({
e2e: {
baseUrl: 'http://localhost:3000',
specPattern: 'cypress/e2e/**/*.cy.{js,jsx,ts,tsx}',
supportFile: 'cypress/support/e2e.ts',
viewportWidth: 1280,
viewportHeight: 720,
video: true,
screenshotOnRunFailure: true,
defaultCommandTimeout: 10000,
retries: {
runMode: 2,
openMode: 0
}
},
component: {
devServer: {
framework: 'vue',
bundler: 'vite'
},
specPattern: 'cypress/component/**/*.cy.{js,jsx,ts,tsx}'
}
})1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
Cypress 基础测试示例
typescript
// cypress/e2e/auth.cy.ts
describe('用户认证', () => {
beforeEach(() => {
cy.visit('/login')
})
it('应该成功登录', () => {
cy.get('[name="email"]').type('test@example.com')
cy.get('[name="password"]').type('password123')
cy.get('button[type="submit"]').click()
// 验证跳转
cy.url().should('include', '/dashboard')
// 验证元素存在
cy.get('.user-avatar').should('be.visible')
cy.get('.welcome-message').should('contain', '欢迎')
})
it('应该显示登录错误', () => {
cy.get('[name="email"]').type('test@example.com')
cy.get('[name="password"]').type('wrongpassword')
cy.get('button[type="submit"]').click()
cy.get('.error-message')
.should('be.visible')
.and('contain', '密码错误')
})
})1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
自定义命令
typescript
// cypress/support/e2e.ts
/// <reference types="cypress" />
// 登录命令
Cypress.Commands.add('login', (email: string, password: string) => {
cy.session([email, password], () => {
cy.visit('/login')
cy.get('[name="email"]').type(email)
cy.get('[name="password"]').type(password)
cy.get('button[type="submit"]').click()
cy.url().should('include', '/dashboard')
})
})
// API 登录命令
Cypress.Commands.add('loginByApi', (email: string, password: string) => {
cy.request('POST', '/api/auth/login', { email, password })
.then((response) => {
window.localStorage.setItem('token', response.body.token)
})
})
// 类型声明
declare global {
namespace Cypress {
interface Chainable {
login(email: string, password: string): Chainable<void>
loginByApi(email: string, password: string): Chainable<void>
}
}
}1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
typescript
// 使用自定义命令
describe('需要登录的测试', () => {
beforeEach(() => {
cy.login('test@example.com', 'password123')
cy.visit('/dashboard')
})
it('应该显示仪表板', () => {
cy.get('.dashboard').should('be.visible')
})
})1
2
3
4
5
6
7
8
9
10
11
2
3
4
5
6
7
8
9
10
11
网络请求 Mock
typescript
// cypress/e2e/api-mock.cy.ts
describe('API Mock 测试', () => {
it('应该 Mock 用户列表', () => {
// 拦截并 Mock 响应
cy.intercept('GET', '/api/users', {
statusCode: 200,
body: [
{ id: 1, name: 'Mock User 1' },
{ id: 2, name: 'Mock User 2' }
]
}).as('getUsers')
cy.visit('/users')
// 等待请求完成
cy.wait('@getUsers')
// 验证 Mock 数据显示
cy.get('.user-list').should('contain', 'Mock User 1')
})
it('应该 Mock 错误响应', () => {
cy.intercept('GET', '/api/users', {
statusCode: 500,
body: { error: 'Server Error' }
}).as('getUsersError')
cy.visit('/users')
cy.wait('@getUsersError')
cy.get('.error-message').should('contain', '加载失败')
})
it('应该 Mock 延迟响应', () => {
cy.intercept('GET', '/api/users', {
delay: 3000,
body: []
}).as('delayedUsers')
cy.visit('/users')
// 验证加载状态
cy.get('.loading-spinner').should('be.visible')
cy.wait('@delayedUsers')
// 验证加载完成
cy.get('.loading-spinner').should('not.exist')
})
})1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
测试场景设计
用户注册流程
typescript
// e2e/register.spec.ts
import { test, expect } from '@playwright/test'
test.describe('用户注册流程', () => {
test('完整注册流程', async ({ page }) => {
// 1. 访问注册页面
await page.goto('/register')
// 2. 填写注册表单
const testEmail = `user-${Date.now()}@example.com`
await page.fill('[name="name"]', 'Test User')
await page.fill('[name="email"]', testEmail)
await page.fill('[name="password"]', 'SecurePass123!')
await page.fill('[name="confirmPassword"]', 'SecurePass123!')
// 3. 同意条款
await page.check('[name="terms"]')
// 4. 提交表单
await page.click('button[type="submit"]')
// 5. 验证跳转到邮件验证页面
await page.waitForURL('/verify-email')
await expect(page.locator('.success-message')).toContainText('验证邮件已发送')
// 6. 模拟点击邮件链接(测试环境)
const verifyLink = await getEmailVerifyLink(testEmail)
await page.goto(verifyLink)
// 7. 验证账户激活
await expect(page.locator('.activation-success')).toBeVisible()
// 8. 自动登录
await page.waitForURL('/dashboard')
await expect(page.locator('.user-avatar')).toBeVisible()
})
test('表单验证', async ({ page }) => {
await page.goto('/register')
// 空表单提交
await page.click('button[type="submit"]')
// 验证必填字段错误
await expect(page.locator('[name="name"] + .error')).toContainText('请输入姓名')
await expect(page.locator('[name="email"] + .error')).toContainText('请输入邮箱')
// 无效邮箱格式
await page.fill('[name="email"]', 'invalid-email')
await page.click('button[type="submit"]')
await expect(page.locator('[name="email"] + .error')).toContainText('邮箱格式不正确')
// 密码强度验证
await page.fill('[name="password"]', '123')
await page.click('button[type="submit"]')
await expect(page.locator('[name="password"] + .error')).toContainText('密码至少8位')
// 密码确认不匹配
await page.fill('[name="password"]', 'SecurePass123!')
await page.fill('[name="confirmPassword"]', 'DifferentPass')
await page.click('button[type="submit"]')
await expect(page.locator('[name="confirmPassword"] + .error')).toContainText('密码不一致')
})
})1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
购物流程
typescript
// e2e/shopping.spec.ts
import { test, expect } from '@playwright/test'
test.describe('购物流程', () => {
test.beforeEach(async ({ page }) => {
// 登录
await page.goto('/login')
await page.fill('[name="email"]', 'buyer@example.com')
await page.fill('[name="password"]', 'password123')
await page.click('button[type="submit"]')
await page.waitForURL('/dashboard')
})
test('完整购物流程', async ({ page }) => {
// 1. 搜索商品
await page.goto('/products')
await page.fill('[name="search"]', 'iPhone')
await page.press('[name="search"]', 'Enter')
// 2. 选择商品
await page.click('.product-card:first-child')
await expect(page.locator('.product-title')).toBeVisible()
// 3. 添加到购物车
await page.selectOption('[name="color"]', 'black')
await page.selectOption('[name="storage"]', '256GB')
await page.click('button:has-text("加入购物车")')
// 4. 验证购物车
await page.click('.cart-icon')
await expect(page.locator('.cart-item')).toHaveCount(1)
await expect(page.locator('.cart-total')).toContainText('¥')
// 5. 结账
await page.click('button:has-text("去结算")')
await page.waitForURL('/checkout')
// 6. 填写收货地址
await page.fill('[name="address"]', '北京市朝阳区xxx街道')
await page.fill('[name="phone"]', '13800138000')
// 7. 选择支付方式
await page.click('[data-payment="alipay"]')
// 8. 提交订单
await page.click('button:has-text("提交订单")')
// 9. 验证订单创建
await page.waitForURL(/\/orders\/\d+/)
await expect(page.locator('.order-status')).toContainText('待支付')
})
test('购物车操作', async ({ page }) => {
await page.goto('/products')
// 添加多个商品
await page.click('.product-card:nth-child(1) button:has-text("加入购物车")')
await page.click('.product-card:nth-child(2) button:has-text("加入购物车")')
// 查看购物车
await page.click('.cart-icon')
await expect(page.locator('.cart-item')).toHaveCount(2)
// 修改数量
const firstItem = page.locator('.cart-item:first-child')
await firstItem.locator('.quantity-input').fill('3')
await expect(firstItem.locator('.item-total')).toContainText('¥')
// 删除商品
await firstItem.locator('.remove-button').click()
await expect(page.locator('.cart-item')).toHaveCount(1)
// 清空购物车
await page.click('button:has-text("清空购物车")')
await expect(page.locator('.empty-cart-message')).toBeVisible()
})
})1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
数据表格操作
typescript
// e2e/datatable.spec.ts
import { test, expect } from '@playwright/test'
test.describe('数据表格', () => {
test('表格基本操作', async ({ page }) => {
await page.goto('/admin/users')
// 等待表格加载
await expect(page.locator('.data-table')).toBeVisible()
// 验证表格数据
const rows = page.locator('.data-table tbody tr')
await expect(rows).toHaveCountGreaterThan(0)
// 排序
await page.click('th:has-text("姓名") .sort-icon')
await expect(page.locator('.data-table')).toHaveAttribute('data-sorted', 'name')
// 筛选
await page.fill('[name="search"]', 'John')
await page.press('[name="search"]', 'Enter')
await expect(page.locator('.data-table tbody tr')).toHaveCount(1)
// 分页
await page.click('.pagination .next')
await expect(page.locator('.pagination .current')).toContainText('2')
// 选择行
await page.click('.data-table tbody tr:first-child .checkbox')
await expect(page.locator('.selected-count')).toContainText('已选择 1 项')
// 批量操作
await page.click('button:has-text("批量删除")')
await page.click('.confirm-dialog button:has-text("确认")')
await expect(page.locator('.toast-message')).toContainText('删除成功')
})
test('表格编辑', async ({ page }) => {
await page.goto('/admin/users')
// 行内编辑
const firstRow = page.locator('.data-table tbody tr:first-child')
await firstRow.locator('.edit-button').click()
// 修改数据
await firstRow.locator('[name="name"]').fill('Updated Name')
await firstRow.locator('.save-button').click()
// 验证更新
await expect(firstRow.locator('td:nth-child(2)')).toContainText('Updated Name')
await expect(page.locator('.toast-message')).toContainText('保存成功')
})
})1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
CI 集成
GitHub Actions 配置
yaml
# .github/workflows/e2e.yml
name: E2E Tests
on:
push:
branches: [main, develop]
pull_request:
branches: [main]
jobs:
e2e:
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
browser: [chromium, firefox, webkit]
steps:
- uses: actions/checkout@v4
- name: Setup Node.js
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
- name: Build application
run: npm run build
- name: Run E2E tests
run: npx playwright test --project=${{ matrix.browser }}
env:
CI: true
- name: Upload test results
uses: actions/upload-artifact@v3
if: always()
with:
name: playwright-report-${{ matrix.browser }}
path: playwright-report/
retention-days: 30
- name: Upload screenshots
uses: actions/upload-artifact@v3
if: failure()
with:
name: screenshots-${{ matrix.browser }}
path: test-results/1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
Docker 环境测试
dockerfile
# Dockerfile.e2e
FROM mcr.microsoft.com/playwright:v1.40.0-jammy
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
RUN npx playwright install
CMD ["npx", "playwright", "test"]1
2
3
4
5
6
7
8
9
10
11
12
13
2
3
4
5
6
7
8
9
10
11
12
13
yaml
# docker-compose.e2e.yml
version: '3.8'
services:
app:
build: .
ports:
- "3000:3000"
environment:
- NODE_ENV=test
- DATABASE_URL=postgresql://test:test@db:5432/test
db:
image: postgres:15
environment:
POSTGRES_USER: test
POSTGRES_PASSWORD: test
POSTGRES_DB: test
e2e:
build:
context: .
dockerfile: Dockerfile.e2e
depends_on:
- app
- db
environment:
- BASE_URL=http://app:30001
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
测试报告
typescript
// playwright.config.ts - 报告配置
export default defineConfig({
reporter: [
// HTML 报告
['html', {
outputFolder: 'playwright-report',
open: 'never'
}],
// JUnit 报告(CI 使用)
['junit', {
outputFile: 'test-results/junit.xml'
}],
// 控制台输出
['list'],
// GitHub Actions 注解
['github']
]
})1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
最佳实践
测试稳定性
typescript
// ✅ 正确:使用显式等待
test('应该显示数据', async ({ page }) => {
await page.goto('/data')
// 等待元素出现
await expect(page.locator('.data-loaded')).toBeVisible({ timeout: 10000 })
// 等待请求完成
await page.waitForResponse(resp =>
resp.url().includes('/api/data') && resp.status() === 200
)
// 等待特定状态
await page.waitForFunction(() => {
return document.querySelector('.loading') === null
})
})
// ❌ 错误:使用固定等待
test('不稳定测试', async ({ page }) => {
await page.goto('/data')
await page.waitForTimeout(5000) // 不推荐
await page.click('.button')
})1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
测试隔离
typescript
// ✅ 正确:每个测试独立
test.describe('用户管理', () => {
test.use({ storageState: { cookies: [], origins: [] } })
test.beforeEach(async ({ page }) => {
// 每个测试前重置状态
await page.goto('/')
})
test('测试1', async ({ page }) => {
// 独立测试
})
test('测试2', async ({ page }) => {
// 独立测试,不依赖测试1
})
})1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
选择器策略
typescript
// ✅ 推荐的选择器策略
test('选择器示例', async ({ page }) => {
// 1. 使用 test-id(最推荐)
await page.click('[data-testid="submit-button"]')
// 2. 使用语义化角色
await page.getByRole('button', { name: '提交' })
await page.getByRole('textbox', { name: '邮箱' })
// 3. 使用文本内容
await page.getByText('欢迎回来')
// 4. 使用标签
await page.getByLabel('密码')
// 5. 使用占位符
await page.getByPlaceholder('请输入邮箱')
})
// ❌ 不推荐的选择器
test('不推荐的选择器', async ({ page }) => {
// 依赖 CSS 类名(可能变化)
await page.click('.btn-primary')
// 依赖 DOM 结构(脆弱)
await page.click('div > div > button')
// 使用 nth(不稳定)
await page.click('button:nth-child(3)')
})1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
测试数据管理
typescript
// e2e/utils/test-data.ts
export class TestDataManager {
private static instance: TestDataManager
private createdResources: string[] = []
static getInstance(): TestDataManager {
if (!this.instance) {
this.instance = new TestDataManager()
}
return this.instance
}
async createTestUser(api: APIRequestContext, overrides = {}): Promise<User> {
const response = await api.post('/api/users', {
data: {
name: 'Test User',
email: `test-${Date.now()}@example.com`,
...overrides
}
})
const user = await response.json()
this.createdResources.push(`user:${user.id}`)
return user
}
async cleanup(api: APIRequestContext): Promise<void> {
for (const resource of this.createdResources) {
const [type, id] = resource.split(':')
if (type === 'user') {
await api.delete(`/api/users/${id}`)
}
}
this.createdResources = []
}
}
// 使用
test.describe('数据管理', () => {
let dataManager: TestDataManager
test.beforeEach(async ({ request }) => {
dataManager = TestDataManager.getInstance()
})
test.afterEach(async ({ request }) => {
await dataManager.cleanup(request)
})
test('创建用户测试', async ({ request, page }) => {
const user = await dataManager.createTestUser(request, { name: 'John' })
await page.goto(`/users/${user.id}`)
await expect(page.locator('.user-name')).toContainText('John')
})
})1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
并行执行优化
typescript
// playwright.config.ts
export default defineConfig({
// 工作进程数
workers: process.env.CI ? 2 : '50%',
// 项目并行
projects: [
{
name: 'setup',
testMatch: /.*\.setup\.ts/
},
{
name: 'chromium',
use: { ...devices['Desktop Chrome'] },
dependencies: ['setup']
},
{
name: 'firefox',
use: { ...devices['Desktop Firefox'] },
dependencies: ['setup']
}
]
})1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
调试技巧
typescript
// 调试模式
test('调试示例', async ({ page }) => {
await page.goto('/')
// 截图
await page.screenshot({ path: 'debug.png' })
// 打印页面内容
console.log(await page.content())
// 暂停执行
await page.pause()
// 慢速执行
await page.click('.button', { slowMo: 1000 })
// 追踪
await page.context().tracing.start({ screenshots: true, snapshots: true })
// ... 操作 ...
await page.context().tracing.stop({ path: 'trace.zip' })
})1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21