关于 playwright-e2e-builder
用于在现有项目中规划和构建 Playwright 端到端测试套件的技能。它引导团队检查项目、梳理需求、设计测试结构,并配置身份验证、视觉检查和 CI。
主要功能
- 01检查项目结构和现有测试
- 02梳理流程、身份验证与测试数据
- 03规划 Page Object Model 和 fixtures
- 04配置身份验证状态与视觉测试
- 05设置含分片和重试的 CI
适用场景
- 01为现有应用添加 Playwright 测试
- 02覆盖注册、结账或仪表板流程
- 03使用可复用页面对象组织测试
- 04在 CI 中并行运行端到端测试套件
安装
复制下面的命令,在终端中运行。
$
npx skills add https:/ / github.com/ davila7/ claude-code-templates --skill playwright-e2e-builder安装后在 Claude Code 中输入/playwright-e2e-builder确认已加载。
详细介绍
Playwright E2E 测试套件构建器
使用时机
在需要以下操作时使用此技能:
- 在现有项目中从头设置 Playwright
- 为关键用户流程(注册、结账、仪表板)构建 E2E 测试
- 实现 Page Object Model,以构建易于维护的测试架构
- 配置跨测试持久化的身份验证状态
- 使用截图设置视觉回归测试
- 在 CI/CD 中集成 Playwright,并配置分片和重试
阶段 1:探索(计划模式)
进入计划模式。在编写任何测试之前,先探索现有项目:
项目结构
- 查明技术栈:这是 React、Next.js、Vue、SvelteKit 还是其他框架?
- 检查是否已安装 Playwright(
playwright.config.ts、package.json 中的@playwright/test) - 查找现有测试目录(
e2e/、tests/、__tests__/) - 检查 Cypress、Selenium 或其他框架中是否已有 E2E 测试(了解迁移背景)
- 查找开发服务器命令和端口(
npm run dev、next dev等)
应用结构
- 找出主要路由/页面(查看路由器配置、pages 目录或路由文件)
- 查找身份验证流程(登录页面 URL、身份验证 API 端点、令牌存储位置)
- 检查组件中是否有测试 ID(
data-testid、data-test、data-cy属性) - 查找测试可能需要通过其预置数据的 API 路由
- 检查
.env文件中是否有测试专用环境变量
CI/CD
- 检查现有 CI 配置(
.github/workflows/、.gitlab-ci.yml、Jenkinsfile) - 查找 Docker 或 docker-compose 设置(有助于保持测试环境一致)
- 检查是否有暂存/预览环境的 URL 格式
阶段 2:访谈(AskUserQuestion)
使用 AskUserQuestion 澄清需求。分轮提问。
第 1 轮:范围和关键流程
Question: "What are the critical user flows to test?"
Header: "Flows"
multiSelect: true
Options:
- "Authentication (signup, login, logout, password reset)" — Core auth flows
- "Core CRUD (create, read, update, delete main resources)" — Primary data operations
- "Checkout/payments (cart, billing, confirmation)" — E-commerce or payment flows
- "Dashboard/admin (data views, filters, exports)" — Admin panel interactions
Question: "How many pages/routes does the application have approximately?"
Header: "App size"
Options:
- "Small (< 10 routes)" — Landing page, auth, a few feature pages
- "Medium (10-30 routes)" — Multiple feature areas, settings, profiles
- "Large (30+ routes)" — Complex app with many sections and user roles
第 2 轮:测试的身份验证策略
Question: "How does your app handle authentication?"
Header: "Auth type"
Options:
- "Cookie/session based (Recommended)" — Server sets httpOnly cookies after login
- "JWT in localStorage" — Token stored in browser localStorage
- "OAuth/SSO (Google, GitHub, etc.)" — Third-party auth provider redirect flow
- "No auth (public app)" — No login required
Question: "How should tests authenticate?"
Header: "Test auth"
Options:
- "Login via UI once, reuse state (Recommended)" — storageState pattern: login in setup, share cookies across tests
- "API login in beforeEach" — Call auth API directly before each test, skip UI login
- "Seed auth token in fixtures" — Inject pre-generated tokens, no login flow needed
- "Test login UI every time" — Actually test the login form in each test suite
第 3 轮:测试数据和环境
Question: "How should test data be managed?"
Header: "Test data"
Options:
- "API seeding in fixtures (Recommended)" — Call API endpoints to create/clean test data before each test
- "Database seeding (direct SQL)" — Run SQL scripts or ORM commands to populate test database
- "Shared test environment (pre-populated)" — Tests run against a persistent staging environment with existing data
- "Mock API responses" — Intercept network requests and return mock data
Question: "What environment do E2E tests run against?"
Header: "Environment"
Options:
- "Local dev server (Recommended)" — Start dev server before tests, run against localhost
- "Preview/staging URL" — Run against a deployed preview or staging environment
- "Docker Compose stack" — Full stack in containers, tests run outside or inside
第 4 轮:CI 和并行化
Question: "How should tests run in CI?"
Header: "CI"
Options:
- "GitHub Actions (Recommended)" — Native Playwright support with sharding
- "GitLab CI" — Docker-based runners with Playwright image
- "Local only (no CI yet)" — Just local test runs for now
- "Other CI (Jenkins, CircleCI)" — Custom CI configuration
Question: "Do you need visual regression testing?"
Header: "Visual"
Options:
- "No — functional tests only (Recommended)" — Assert behavior, not pixels
- "Yes — screenshot comparisons" — Capture and compare page screenshots
- "Yes — component screenshots" — Capture specific components, not full pages
阶段 3:规划(ExitPlanMode)
编写具体的实施计划,涵盖:
- 目录结构 — 测试文件、页面对象、固定装置、配置
- 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: [
// Auth setup — runs before all tests
{
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 }) => {
// Navigate to login page
await page.goto('/login');
// Fill login form
await page.getByLabel('Email').fill(process.env.TEST_USER_EMAIL || '[email protected]');
await page.getByLabel('Password').fill(process.env.TEST_USER_PASSWORD || 'testpassword');
await page.getByRole('button', { name: 'Sign in' }).click();
// Wait for auth to complete — adjust selector to your app
await page.waitForURL('/dashboard');
await expect(page.getByRole('navigation')).toBeVisible();
// Save signed-in state
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 client for test data seeding
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(`Seed failed: ${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:Page Object Model
// 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);
// Wait for debounced search to trigger
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', () => {
// These tests run WITHOUT storageState (unauthenticated)
test.use({ storageState: { cookies: [], origins: [] } });
test('successful login redirects to dashboard', async ({ loginPage, page }) => {
await loginPage.goto();
await loginPage.login('[email protected]', 'testpassword');
await expect(page).toHaveURL('/dashboard');
});
test('invalid credentials shows error', async ({ loginPage }) => {
await loginPage.goto();
await loginPage.login('[email protected]', 'wrongpassword');
await loginPage.expectError('Invalid credentials');
});
test('logout clears session', async ({ page }) => {
// Login first
await page.goto('/login');
// ... login steps ...
// Logout
await page.getByRole('button', { name: 'Logout' }).click();
await expect(page).toHaveURL('/login');
// Verify can't access protected route
await page.goto('/dashboard');
await expect(page).toHaveURL('/login');
});
});
// e2e/dashboard.spec.ts
import { test, expect } from './fixtures';
test.describe('Dashboard', () => {
test('displays resource list', async ({ dashboardPage }) => {
await dashboardPage.goto();
await expect(dashboardPage.heading).toHaveText('Dashboard');
await expect(dashboardPage.resourceList).toBeVisible();
});
test('create new resource', async ({ dashboardPage, page }) => {
await dashboardPage.goto();
await dashboardPage.createResource('New E2E Resource');
// Verify resource appears in list
await dashboardPage.expectResourceVisible('New E2E Resource');
});
test('search filters results', async ({ dashboardPage, api }) => {
// Seed test data via 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('empty state shown when no resources', 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 }) => {
// Seed a resource for tests that need one
const resource = await api.createResource({ name: 'Test Resource' });
resourceId = resource.id;
});
test.afterEach(async ({ api }) => {
// Clean up seeded data
if (resourceId) {
await api.deleteResource(resourceId).catch(() => {});
}
});
test('edit resource name', 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('delete resource with confirmation', async ({ page }) => {
await page.goto(`/resources/${resourceId}`);
await page.getByRole('button', { name: 'Delete' }).click();
// Confirm deletion dialog
await expect(page.getByRole('dialog')).toBeVisible();
await page.getByRole('button', { name: 'Confirm' }).click();
// Should redirect to list
await expect(page).toHaveURL('/dashboard');
});
});
步骤 6:视觉回归(如已选择)
// e2e/visual.spec.ts
import { test, expect } from './fixtures';
test.describe('Visual regression', () => {
test('dashboard matches snapshot', async ({ dashboardPage, page }) => {
await dashboardPage.goto();
// Wait for dynamic content to stabilize
await page.waitForLoadState('networkidle');
await expect(page).toHaveScreenshot('dashboard.png', {
maxDiffPixelRatio: 0.01,
});
});
test('login page matches snapshot', async ({ loginPage, page }) => {
test.use({ storageState: { cookies: [], origins: [] } });
await loginPage.goto();
await expect(page).toHaveScreenshot('login.png', {
maxDiffPixelRatio: 0.01,
});
});
// Component-level screenshots
test('navigation component matches snapshot', 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 # Saved auth state (gitignored)
├── fixtures.ts # Custom test fixtures and API client
├── pages/
│ ├── login-page.ts # Login page object
│ ├── dashboard-page.ts # Dashboard page object
│ └── resource-page.ts # Resource detail page object
├── auth.setup.ts # Global auth setup (runs once)
├── auth.spec.ts # Authentication tests
├── dashboard.spec.ts # Dashboard tests
├── crud.spec.ts # CRUD operation tests
└── visual.spec.ts # Visual regression tests (optional)
playwright.config.ts # Playwright configuration
最佳实践
优先使用基于角色的定位器
优先使用 getByRole()、getByLabel()、getByText(),而非 CSS 选择器或测试 ID。这些定位器与用户和页面交互的方式一致,也能发现无障碍问题:
// Preferred — accessible and resilient
await page.getByRole('button', { name: 'Submit' }).click();
await page.getByLabel('Email').fill('[email protected]');
// Fallback — when role-based doesn't work
await page.getByTestId('custom-widget').click();
// Avoid — fragile, breaks on refactors
await page.locator('.btn-primary').click();
await page.locator('#email-input').fill('[email protected]');
等待网络,而非计时器
不要使用 page.waitForTimeout()。请等待特定条件:
// Wait for API response
await page.waitForResponse(resp => resp.url().includes('/api/data'));
// Wait for element state
await expect(page.getByText('Saved')).toBeVisible();
// Wait for navigation
await expect(page).toHaveURL('/dashboard');
// Wait for loading to finish
await expect(page.getByTestId('spinner')).toBeHidden();
隔离测试数据
每项测试都应创建自己的数据,并在结束后清理:
test('edit resource', async ({ api, page }) => {
// Arrange — seed via API
const resource = await api.createResource({ name: 'Test' });
// Act
await page.goto(`/resources/${resource.id}`);
// ... test logic ...
// Cleanup (also runs on failure via afterEach)
});
为测试添加标签,以便选择性运行
test('checkout flow @slow @checkout', async ({ page }) => {
// Long test tagged for selective execution
});
// Run only: npx playwright test --grep @checkout
// Skip slow: 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并确认通过
评论
支持 Markdown