$npx -y skills add echoVic/boss-skill --skill e2e-playwrightPlaywright E2E 测试完整方法论,涵盖项目初始化、Page Object Model、认证复用、API Mock、视觉回归、多浏览器测试、CI 集成和调试技巧
| 1 | # Playwright E2E 测试方法论 |
| 2 | |
| 3 | ## 适用场景 |
| 4 | |
| 5 | - Web 项目需要编写端到端测试 |
| 6 | - 门禁(Gate 1)要求 E2E 测试通过 |
| 7 | - 需要覆盖关键用户流程的自动化验证 |
| 8 | - 需要多浏览器/多视口兼容性验证 |
| 9 | - 需要视觉回归测试 |
| 10 | |
| 11 | --- |
| 12 | |
| 13 | ## 1. 项目初始化 |
| 14 | |
| 15 | ### 1.1 安装 |
| 16 | |
| 17 | ```bash |
| 18 | # 新项目初始化(推荐) |
| 19 | npm init playwright@latest |
| 20 | |
| 21 | # 已有项目添加 |
| 22 | npm install -D @playwright/test |
| 23 | npx playwright install |
| 24 | ``` |
| 25 | |
| 26 | ### 1.2 配置文件(`playwright.config.ts`) |
| 27 | |
| 28 | ```typescript |
| 29 | import { defineConfig, devices } from '@playwright/test'; |
| 30 | |
| 31 | export default defineConfig({ |
| 32 | testDir: './e2e', |
| 33 | // 测试产物目录 |
| 34 | outputDir: './e2e/test-results', |
| 35 | |
| 36 | // 全局超时 |
| 37 | timeout: 30_000, |
| 38 | expect: { timeout: 5_000 }, |
| 39 | |
| 40 | // 并行执行 |
| 41 | fullyParallel: true, |
| 42 | workers: process.env.CI ? 1 : undefined, |
| 43 | |
| 44 | // 失败重试(CI 中重试一次减少 flaky) |
| 45 | retries: process.env.CI ? 1 : 0, |
| 46 | |
| 47 | // 报告 |
| 48 | reporter: [ |
| 49 | ['html', { outputFolder: './e2e/playwright-report' }], |
| 50 | ['json', { outputFile: './e2e/test-results/results.json' }], |
| 51 | // CI 中额外输出到 stdout |
| 52 | ...(process.env.CI ? [['github'] as const] : []), |
| 53 | ], |
| 54 | |
| 55 | // 全局配置 |
| 56 | use: { |
| 57 | baseURL: process.env.BASE_URL || 'http://localhost:3000', |
| 58 | // 失败时自动截图 |
| 59 | screenshot: 'only-on-failure', |
| 60 | // 失败时录制 trace |
| 61 | trace: 'on-first-retry', |
| 62 | // 失败时录制视频 |
| 63 | video: 'on-first-retry', |
| 64 | }, |
| 65 | |
| 66 | // 多浏览器 + 移动端视口 |
| 67 | projects: [ |
| 68 | { name: 'chromium', use: { ...devices['Desktop Chrome'] } }, |
| 69 | { name: 'firefox', use: { ...devices['Desktop Firefox'] } }, |
| 70 | { name: 'webkit', use: { ...devices['Desktop Safari'] } }, |
| 71 | { name: 'mobile-chrome', use: { ...devices['Pixel 5'] } }, |
| 72 | { name: 'mobile-safari', use: { ...devices['iPhone 13'] } }, |
| 73 | ], |
| 74 | |
| 75 | // 开发服务器自动启动 |
| 76 | webServer: { |
| 77 | command: 'npm run dev', |
| 78 | url: 'http://localhost:3000', |
| 79 | reuseExistingServer: !process.env.CI, |
| 80 | timeout: 120_000, |
| 81 | }, |
| 82 | }); |
| 83 | ``` |
| 84 | |
| 85 | **关键配置说明**: |
| 86 | |
| 87 | | 配置项 | 作用 | 建议值 | |
| 88 | |--------|------|--------| |
| 89 | | `fullyParallel` | 测试文件间并行执行 | `true` | |
| 90 | | `workers` | 并行 worker 数 | CI 为 1,本地默认 | |
| 91 | | `retries` | 失败重试次数 | CI 为 1,本地为 0 | |
| 92 | | `trace` | 失败时生成可视化时间线 | `on-first-retry` | |
| 93 | | `webServer` | 自动启动开发服务器 | 必须配置 | |
| 94 | |
| 95 | ### 1.3 目录结构 |
| 96 | |
| 97 | ``` |
| 98 | e2e/ |
| 99 | ├── playwright.config.ts # 配置文件(或放在项目根目录) |
| 100 | ├── fixtures/ # 自定义 fixtures |
| 101 | │ ├── base.ts # 扩展 base test |
| 102 | │ └── auth.ts # 认证 fixture |
| 103 | ├── pages/ # Page Object Models |
| 104 | │ ├── login.page.ts |
| 105 | │ ├── dashboard.page.ts |
| 106 | │ └── components/ # 可复用组件 POM |
| 107 | │ ├── navbar.component.ts |
| 108 | │ └── modal.component.ts |
| 109 | ├── specs/ # 测试用例 |
| 110 | │ ├── auth/ |
| 111 | │ │ ├── login.spec.ts |
| 112 | │ │ └── register.spec.ts |
| 113 | │ ├── dashboard/ |
| 114 | │ │ └── dashboard.spec.ts |
| 115 | │ └── crud/ |
| 116 | │ └── user-management.spec.ts |
| 117 | ├── helpers/ # 测试工具 |
| 118 | │ ├── seed.ts # 数据种子 |
| 119 | │ └── cleanup.ts # 数据清理 |
| 120 | ├── test-results/ # 测试产物(gitignore) |
| 121 | └── playwright-report/ # HTML 报告(gitignore) |
| 122 | ``` |
| 123 | |
| 124 | --- |
| 125 | |
| 126 | ## 2. Page Object Model(POM) |
| 127 | |
| 128 | ### 2.1 核心原则 |
| 129 | |
| 130 | - **每个页面一个 POM 类**:封装定位器和操作方法 |
| 131 | - **不暴露 Locator**:外部只调用语义化方法 |
| 132 | - **组件级复用**:导航栏、弹窗等提取为独立组件 POM |
| 133 | |
| 134 | ### 2.2 基础 POM |
| 135 | |
| 136 | ```typescript |
| 137 | // e2e/pages/login.page.ts |
| 138 | import { type Page, type Locator } from '@playwright/test'; |
| 139 | |
| 140 | export class LoginPage { |
| 141 | private readonly emailInput: Locator; |
| 142 | private readonly passwordInput: Locator; |
| 143 | private readonly submitButton: Locator; |
| 144 | private readonly errorMessage: Locator; |
| 145 | |
| 146 | constructor(private readonly page: Page) { |
| 147 | this.emailInput = page.getByLabel('邮箱'); |
| 148 | this.passwordInput = page.getByLabel('密码'); |
| 149 | this.submitButton = page.getByRole('button', { name: '登录' }); |
| 150 | this.errorMessage = page.getByRole('alert'); |
| 151 | } |
| 152 | |
| 153 | async goto() { |
| 154 | await this.page.goto('/login'); |
| 155 | } |
| 156 | |
| 157 | async login(email: string, password: string) { |
| 158 | await this.emailInput.fill(email); |
| 159 | await this.passwordInput.fill(password); |
| 160 | await this.submitButton.click(); |
| 161 | } |
| 162 | |
| 163 | async getErrorMessage() { |
| 164 | return this.errorMessage.textContent(); |
| 165 | } |
| 166 | } |
| 167 | ``` |
| 168 | |
| 169 | ### 2.3 组件 POM |
| 170 | |
| 171 | ```typescript |
| 172 | // e2e/pages/components/navbar.component.ts |
| 173 | import { type Page, type Locator } from '@playwright/test'; |
| 174 | |
| 175 | export class NavbarComponent { |
| 176 | private readonly userMenu: Locator; |
| 177 | private readonly logoutButton: Locator; |
| 178 | |
| 179 | constructor(private readonly page: Page) { |
| 180 | this.userMenu = page.getByTestId('user-menu'); |
| 181 | this.logoutButton = page.getByRole('menuitem', { name: '退出登录' }); |
| 182 | } |
| 183 | |
| 184 | async logout() { |
| 185 | await this.userMenu.click(); |
| 186 | await this.logoutButton.click(); |
| 187 | } |
| 188 | |
| 189 | async getUserDisplayName() { |
| 190 | return this.userMenu.textContent(); |
| 191 | } |
| 192 | } |
| 193 | ``` |
| 194 | |
| 195 | ### 2.4 定位器优先级 |
| 196 | |
| 197 | 选择定位器时遵循以下优先级(可靠性从高到低): |
| 198 | |
| 199 | | 优先级 | 方法 | 示例 | 说明 | |
| 200 | |--------|------|------|------| |
| 201 | | 1 | `getByRole` | `getByRole('button', { name: '提交' })` | 无障碍语义,最稳定 | |
| 202 | | 2 | `getByLabel` | `getByLabel('邮箱')` | 表单元素首选 | |
| 203 | | 3 | `getByPlaceholder` | |