name: playwright-e2e-builder description: 使用页面对象模型、认证状态持久化、自定义夹具、视觉回归和CI集成,规划和构建全面的Playwright端到端测试套件。采用访谈驱动规划,在编写任何测试之前澄清关键用户流、认证策略、测试数据方法和并行化。 tags: [playwright, e2e, testing, automation, typescript, ci, visual-regression]
Playwright端到端测试套件构建器
使用时机
在以下情况使用此技能:
- 在现有项目中从零开始设置Playwright
- 为关键用户流(注册、结账、仪表板)构建端到端测试
- 实现页面对象模型以维护可维护的测试架构
- 配置跨测试的认证状态持久化
- 设置带有截图的视觉回归测试
- 通过分片和重试将Playwright集成到CI/CD中
阶段1:探索(规划模式)
进入规划模式。在编写任何测试之前,探索现有项目:
项目结构
- 查找技术栈:这是React、Next.js、Vue、SvelteKit还是其他框架?
- 检查是否已安装Playwright(
playwright.config.ts、package.json中的@playwright/test) - 查找现有测试目录(
e2e/、tests/、__tests__/) - 检查是否存在Cypress、Selenium或其他框架的现有端到端测试(迁移上下文)
- 查找开发服务器命令和端口(
npm run dev、next dev等)
应用结构
- 识别主要路由/页面(查看路由器配置、页面目录或路由文件)
- 查找认证流程(登录页面URL、认证API端点、令牌存储)
- 检查组件中的测试ID(
data-testid、data-test、data-cy属性) - 查找测试可能需要通过API路由播种数据的API
- 检查
.env文件以获取测试特定的环境变量
CI/CD
- 检查现有CI配置(
.github/workflows/、.gitlab-ci.yml、Jenkinsfile) - 查找Docker或docker-compose设置(用于一致的测试环境)
- 检查是否存在暂存/预览环境URL模式
阶段2:访谈(询问用户问题)
使用AskUserQuestion澄清需求。分轮询问。
第1轮:范围和关键流
问题:“需要测试哪些关键用户流?”
标题:“流”
多选:true
选项:
- “认证(注册、登录、注销、密码重置)” — 核心认证流
- “核心CRUD(创建、读取、更新、删除主要资源)” — 主要数据操作
- “结账/支付(购物车、账单、确认)” — 电子商务或支付流
- “仪表板/管理(数据视图、过滤器、导出)” — 管理面板交互
问题:“应用大约有多少页面/路由?”
标题:“应用大小”
选项:
- “小(< 10路由)” — 着陆页、认证、几个功能页面
- “中等(10-30路由)” — 多个功能区域、设置、配置文件
- “大(30+路由)” — 复杂应用,包含许多部分和用户角色
第2轮:测试认证策略
问题:“您的应用如何处理认证?”
标题:“认证类型”
选项:
- “基于Cookie/会话(推荐)” — 登录后服务器设置httpOnly cookie
- “localStorage中的JWT” — 令牌存储在浏览器localStorage中
- “OAuth/SSO(Google、GitHub等)” — 第三方认证提供者重定向流
- “无认证(公共应用)” — 无需登录
问题:“测试应如何认证?”
标题:“测试认证”
选项:
- “通过UI登录一次,重用状态(推荐)” — storageState模式:在设置中登录,跨测试共享cookie
- “在每个测试前通过API登录” — 直接调用认证API,跳过UI登录
- “在夹具中播种认证令牌” — 注入预生成的令牌,无需登录流
- “每次测试登录UI” — 实际在每个测试套件中测试登录表单
第3轮:测试数据和环境
问题:“应如何管理测试数据?”
标题:“测试数据”
选项:
- “夹具中的API播种(推荐)” — 调用API端点创建/清理每个测试前的测试数据
- “数据库播种(直接SQL)” — 运行SQL脚本或ORM命令填充测试数据库
- “共享测试环境(预填充)” — 测试针对具有现有数据的持久暂存环境运行
- “模拟API响应” — 拦截网络请求并返回模拟数据
问题:“端到端测试针对什么环境运行?”
标题:“环境”
选项:
- “本地开发服务器(推荐)” — 测试前启动开发服务器,针对localhost运行
- “预览/暂存URL” — 针对已部署的预览或暂存环境运行
- “Docker Compose堆栈” — 容器中的完整堆栈,测试在外部或内部运行
第4轮:CI和并行化
问题:“测试在CI中应如何运行?”
标题:“CI”
选项:
- “GitHub Actions(推荐)” — 原生Playwright支持,带分片
- “GitLab CI” — 基于Docker的运行器,带Playwright镜像
- “仅本地(尚无CI)” — 目前仅本地测试运行
- “其他CI(Jenkins、CircleCI)” — 自定义CI配置
问题:“需要视觉回归测试吗?”
标题:“视觉”
选项:
- “否 — 仅功能测试(推荐)” — 断言行为,而非像素
- “是 — 截图比较” — 捕获并比较页面截图
- “是 — 组件截图” — 捕获特定组件,而非全页面
阶段3:规划(退出规划模式)
编写具体的实施计划,涵盖:
- 目录结构 — 测试文件、页面对象、夹具、配置
- Playwright配置 — 项目(浏览器)、基础URL、重试次数、工作进程
- 认证设置 — 用于storageState或基于API认证的全局设置
- 页面对象 — 每个页面的类,包含定位器和操作
- 测试夹具 — 用于数据播种、认证、API客户端的自定义夹具
- 测试套件 — 每个关键流的测试文件,来自访谈
- CI配置 — 工作流程文件,带分片、工件上传、报告
通过ExitPlanMode呈现,供用户批准。
阶段4:执行
批准后,按此顺序实施:
步骤1:Playwright配置
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: process.env.CI
? [['html', { open: 'never' }], ['github']]
: [['html', { open: 'on-failure' }]],
use: {
baseURL: process.env.BASE_URL || 'http://localhost:3000',
trace: 'on-first-retry',
screenshot: 'only-on-failure',
video: 'on-first-retry',
},
projects: [
// 认证设置 — 在所有测试前运行
{
name: 'setup',
testMatch: /.*\.setup\.ts/,
},
{
name: 'chromium',
use: {
...devices['Desktop Chrome'],
storageState: 'e2e/.auth/user.json',
},
dependencies: ['setup'],
},
{
name: 'firefox',
use: {
...devices['Desktop Firefox'],
storageState: 'e2e/.auth/user.json',
},
dependencies: ['setup'],
},
{
name: 'mobile',
use: {
...devices['iPhone 14'],
storageState: 'e2e/.auth/user.json',
},
dependencies: ['setup'],
},
],
webServer: {
command: 'npm run dev',
url: 'http://localhost:3000',
reuseExistingServer: !process.env.CI,
timeout: 120_000,
},
});
步骤2:认证设置(全局)
// e2e/auth.setup.ts
import { test as setup, expect } from '@playwright/test';
const authFile = 'e2e/.auth/user.json';
setup('authenticate', async ({ page }) => {
// 导航到登录页面
await page.goto('/login');
// 填写登录表单
await page.getByLabel('Email').fill(process.env.TEST_USER_EMAIL || 'test@example.com');
await page.getByLabel('Password').fill(process.env.TEST_USER_PASSWORD || 'testpassword');
await page.getByRole('button', { name: 'Sign in' }).click();
// 等待认证完成 — 根据您的应用调整选择器
await page.waitForURL('/dashboard');
await expect(page.getByRole('navigation')).toBeVisible();
// 保存登录状态
await page.context().storageState({ path: authFile });
});
步骤3:自定义夹具
// e2e/fixtures.ts
import { test as base, expect } from '@playwright/test';
import { LoginPage } from './pages/login-page';
import { DashboardPage } from './pages/dashboard-page';
// 用于测试数据播种的API客户端
class ApiClient {
constructor(private baseURL: string, private token?: string) {}
async createResource(data: Record<string, unknown>) {
const response = await fetch(`${this.baseURL}/api/resources`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
...(this.token ? { Authorization: `Bearer ${this.token}` } : {}),
},
body: JSON.stringify(data),
});
if (!response.ok) throw new Error(`播种失败: ${response.status}`);
return response.json();
}
async deleteResource(id: string) {
await fetch(`${this.baseURL}/api/resources/${id}`, {
method: 'DELETE',
headers: this.token ? { Authorization: `Bearer ${this.token}` } : {},
});
}
}
type Fixtures = {
loginPage: LoginPage;
dashboardPage: DashboardPage;
api: ApiClient;
};
export const test = base.extend<Fixtures>({
loginPage: async ({ page }, use) => {
await use(new LoginPage(page));
},
dashboardPage: async ({ page }, use) => {
await use(new DashboardPage(page));
},
api: async ({ baseURL }, use) => {
const client = new ApiClient(baseURL!);
await use(client);
},
});
export { expect };
步骤4:页面对象模型
// e2e/pages/login-page.ts
import { type Page, type Locator, expect } from '@playwright/test';
export class LoginPage {
readonly emailInput: Locator;
readonly passwordInput: Locator;
readonly submitButton: Locator;
readonly errorMessage: Locator;
constructor(private page: 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.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/dashboard-page.ts
import { type Page, type Locator, expect } from '@playwright/test';
export class DashboardPage {
readonly heading: Locator;
readonly createButton: Locator;
readonly searchInput: Locator;
readonly resourceList: Locator;
constructor(private page: Page) {
this.heading = page.getByRole('heading', { level: 1 });
this.createButton = page.getByRole('button', { name: 'Create' });
this.searchInput = page.getByPlaceholder('Search');
this.resourceList = page.getByTestId('resource-list');
}
async goto() {
await this.page.goto('/dashboard');
}
async createResource(name: string) {
await this.createButton.click();
await this.page.getByLabel('Name').fill(name);
await this.page.getByRole('button', { name: 'Save' }).click();
}
async search(query: string) {
await this.searchInput.fill(query);
// 等待去抖搜索触发
await this.page.waitForResponse(resp =>
resp.url().includes('/api/resources') && resp.status() === 200
);
}
async expectResourceVisible(name: string) {
await expect(this.resourceList.getByText(name)).toBeVisible();
}
async expectResourceCount(count: number) {
await expect(this.resourceList.getByRole('listitem')).toHaveCount(count);
}
}
步骤5:测试套件
// e2e/auth.spec.ts
import { test, expect } from './fixtures';
test.describe('Authentication', () => {
// 这些测试在没有storageState的情况下运行(未认证)
test.use({ storageState: { cookies: [], origins: [] } });
test('成功登录重定向到仪表板', async ({ loginPage, page }) => {
await loginPage.goto();
await loginPage.login('test@example.com', 'testpassword');
await expect(page).toHaveURL('/dashboard');
});
test('无效凭据显示错误', async ({ loginPage }) => {
await loginPage.goto();
await loginPage.login('test@example.com', 'wrongpassword');
await loginPage.expectError('Invalid credentials');
});
test('注销清除会话', async ({ page }) => {
// 先登录
await page.goto('/login');
// ... 登录步骤 ...
// 注销
await page.getByRole('button', { name: 'Logout' }).click();
await expect(page).toHaveURL('/login');
// 验证无法访问受保护路由
await page.goto('/dashboard');
await expect(page).toHaveURL('/login');
});
});
// e2e/dashboard.spec.ts
import { test, expect } from './fixtures';
test.describe('Dashboard', () => {
test('显示资源列表', async ({ dashboardPage }) => {
await dashboardPage.goto();
await expect(dashboardPage.heading).toHaveText('Dashboard');
await expect(dashboardPage.resourceList).toBeVisible();
});
test('创建新资源', async ({ dashboardPage, page }) => {
await dashboardPage.goto();
await dashboardPage.createResource('New E2E Resource');
// 验证资源出现在列表中
await dashboardPage.expectResourceVisible('New E2E Resource');
});
test('搜索过滤结果', async ({ dashboardPage, api }) => {
// 通过API播种测试数据
await api.createResource({ name: 'Alpha Item' });
await api.createResource({ name: 'Beta Item' });
await dashboardPage.goto();
await dashboardPage.search('Alpha');
await dashboardPage.expectResourceVisible('Alpha Item');
});
test('无资源时显示空状态', async ({ dashboardPage, page }) => {
await dashboardPage.goto();
await dashboardPage.search('nonexistent-query-xyz');
await expect(page.getByText('No results found')).toBeVisible();
});
});
// e2e/crud.spec.ts
import { test, expect } from './fixtures';
test.describe('Resource CRUD', () => {
let resourceId: string;
test.beforeEach(async ({ api }) => {
// 为需要资源的测试播种
const resource = await api.createResource({ name: 'Test Resource' });
resourceId = resource.id;
});
test.afterEach(async ({ api }) => {
// 清理播种的数据
if (resourceId) {
await api.deleteResource(resourceId).catch(() => {});
}
});
test('编辑资源名称', async ({ page }) => {
await page.goto(`/resources/${resourceId}`);
await page.getByRole('button', { name: 'Edit' }).click();
await page.getByLabel('Name').clear();
await page.getByLabel('Name').fill('Updated Resource');
await page.getByRole('button', { name: 'Save' }).click();
await expect(page.getByRole('heading')).toHaveText('Updated Resource');
});
test('带确认的删除资源', async ({ page }) => {
await page.goto(`/resources/${resourceId}`);
await page.getByRole('button', { name: 'Delete' }).click();
// 确认删除对话框
await expect(page.getByRole('dialog')).toBeVisible();
await page.getByRole('button', { name: 'Confirm' }).click();
// 应重定向到列表
await expect(page).toHaveURL('/dashboard');
});
});
步骤6:视觉回归(如果选择)
// e2e/visual.spec.ts
import { test, expect } from './fixtures';
test.describe('Visual regression', () => {
test('仪表板匹配快照', async ({ dashboardPage, page }) => {
await dashboardPage.goto();
// 等待动态内容稳定
await page.waitForLoadState('networkidle');
await expect(page).toHaveScreenshot('dashboard.png', {
maxDiffPixelRatio: 0.01,
});
});
test('登录页面匹配快照', async ({ loginPage, page }) => {
test.use({ storageState: { cookies: [], origins: [] } });
await loginPage.goto();
await expect(page).toHaveScreenshot('login.png', {
maxDiffPixelRatio: 0.01,
});
});
// 组件级截图
test('导航组件匹配快照', async ({ page }) => {
await page.goto('/dashboard');
const nav = page.getByRole('navigation');
await expect(nav).toHaveScreenshot('navigation.png');
});
});
步骤7:GitHub Actions CI
# .github/workflows/e2e.yml
name: E2E Tests
on:
push:
branches: [main]
pull_request:
branches: [main]
jobs:
e2e:
timeout-minutes: 30
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
shard: [1/4, 2/4, 3/4, 4/4]
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 20
cache: 'npm'
- run: npm ci
- name: Install Playwright browsers
run: npx playwright install --with-deps chromium
- name: Run E2E tests
run: npx playwright test --shard=${{ matrix.shard }}
env:
BASE_URL: http://localhost:3000
TEST_USER_EMAIL: ${{ secrets.TEST_USER_EMAIL }}
TEST_USER_PASSWORD: ${{ secrets.TEST_USER_PASSWORD }}
- name: Upload test report
uses: actions/upload-artifact@v4
if: ${{ !cancelled() }}
with:
name: playwright-report-${{ strategy.job-index }}
path: playwright-report/
retention-days: 14
- name: Upload test results
uses: actions/upload-artifact@v4
if: ${{ !cancelled() }}
with:
name: test-results-${{ strategy.job-index }}
path: test-results/
retention-days: 7
目录结构参考
e2e/
├── .auth/
│ └── user.json # 保存的认证状态(git忽略)
├── fixtures.ts # 自定义测试夹具和API客户端
├── pages/
│ ├── login-page.ts # 登录页面对象
│ ├── dashboard-page.ts # 仪表板页面对象
│ └── resource-page.ts # 资源详情页面对象
├── auth.setup.ts # 全局认证设置(运行一次)
├── auth.spec.ts # 认证测试
├── dashboard.spec.ts # 仪表板测试
├── crud.spec.ts # CRUD操作测试
└── visual.spec.ts # 视觉回归测试(可选)
playwright.config.ts # Playwright配置
最佳实践
优先使用基于角色的定位器
首选getByRole()、getByLabel()、getByText()而非CSS选择器或测试ID。这些定位器反映用户如何与页面交互,并能捕捉可访问性问题:
// 推荐 — 可访问且弹性强
await page.getByRole('button', { name: 'Submit' }).click();
await page.getByLabel('Email').fill('user@test.com');
// 备用 — 当基于角色的定位器无效时
await page.getByTestId('custom-widget').click();
// 避免 — 脆弱,重构时易破坏
await page.locator('.btn-primary').click();
await page.locator('#email-input').fill('user@test.com');
等待网络,而非定时器
切勿使用page.waitForTimeout()。等待特定条件:
// 等待API响应
await page.waitForResponse(resp => resp.url().includes('/api/data'));
// 等待元素状态
await expect(page.getByText('Saved')).toBeVisible();
// 等待导航
await expect(page).toHaveURL('/dashboard');
// 等待加载完成
await expect(page.getByTestId('spinner')).toBeHidden();
隔离测试数据
每个测试应创建自己的数据并清理:
test('编辑资源', async ({ api, page }) => {
// 安排 — 通过API播种
const resource = await api.createResource({ name: 'Test' });
// 执行
await page.goto(`/resources/${resource.id}`);
// ... 测试逻辑 ...
// 清理(也通过afterEach在失败时运行)
});
标记测试以选择性运行
test('结账流 @slow @checkout', async ({ page }) => {
// 长测试标记为选择性执行
});
// 仅运行:npx playwright test --grep @checkout
// 跳过慢速:npx playwright test --grep-invert @slow
.gitignore添加
# Playwright
e2e/.auth/
test-results/
playwright-report/
blob-report/
完成前检查清单
- [ ]
playwright.config.ts已配置webServer以启动开发服务器 - [ ] 认证设置保存storageState,所有测试项目依赖它
- [ ] 页面对象使用基于角色的定位器(
getByRole、getByLabel、getByText) - [ ] 无
waitForTimeout()调用 — 仅等待元素、URL或响应 - [ ] 测试创建并清理自己的数据(无共享可变状态)
- [ ] CI配置有分片以并行执行
- [ ] 跟踪、截图和视频在失败时捕获以供调试
- [ ]
.auth/目录在.gitignore中 - [ ] 推送前
npx playwright test在本地通过