playwright-e2e-builder
Compare original and translation side by side
🇺🇸
Original
English🇨🇳
Translation
ChinesePlaywright E2E Test Suite Builder
Playwright 端到端测试套件构建指南
When to use
适用场景
Use this skill when you need to:
- Set up Playwright from scratch in an existing project
- Build E2E tests for critical user flows (signup, checkout, dashboards)
- Implement Page Object Model for maintainable test architecture
- Configure authentication state persistence across tests
- Set up visual regression testing with screenshots
- Integrate Playwright into CI/CD with sharding and retries
当你需要以下功能时使用本技能:
- 在现有项目中从零开始搭建Playwright
- 为关键用户流程(注册、结账、仪表盘)构建端到端测试
- 实现页面对象模型以打造可维护的测试架构
- 配置跨测试的认证状态持久化
- 设置基于截图的视觉回归测试
- 将Playwright集成到CI/CD流程,支持分片执行与重试机制
Phase 1: Explore (Plan Mode)
阶段1:探索(规划模式)
Enter plan mode. Before writing any tests, explore the existing project:
进入规划模式。在编写任何测试前,先探索现有项目:
Project structure
项目结构
- Find the tech stack: is this React, Next.js, Vue, SvelteKit, or another framework?
- Check if Playwright is already installed (,
playwright.config.tsin package.json)@playwright/test - Look for existing test directories (,
e2e/,tests/)__tests__/ - Check for existing E2E tests in Cypress, Selenium, or other frameworks (migration context)
- Find the dev server command and port (,
npm run dev, etc.)next dev
- 确定技术栈:是React、Next.js、Vue、SvelteKit还是其他框架?
- 检查是否已安装Playwright(查看、package.json中的
playwright.config.ts)@playwright/test - 寻找现有测试目录(、
e2e/、tests/)__tests__/ - 检查是否存在基于Cypress、Selenium或其他框架的现有端到端测试(迁移参考)
- 找到开发服务器命令与端口(、
npm run dev等)next dev
Application structure
应用结构
- Identify the main routes/pages (look at router config, pages directory, or route files)
- Find authentication flow (login page URL, auth API endpoints, token storage)
- Check for test IDs in components (,
data-testid,data-testattributes)data-cy - Look for API routes that tests might need to seed data through
- Check files for test-specific environment variables
.env
- 识别主要路由/页面(查看路由配置、pages目录或路由文件)
- 梳理认证流程(登录页面URL、认证API端点、令牌存储方式)
- 检查组件中的测试ID(、
data-testid、data-test属性)data-cy - 寻找可用于预填充测试数据的API路由
- 检查文件中的测试专属环境变量
.env
CI/CD
CI/CD
- Check for existing CI config (,
.github/workflows/,.gitlab-ci.yml)Jenkinsfile - Look for Docker or docker-compose setup (useful for consistent test environments)
- Check if there's a staging/preview environment URL pattern
- 检查现有CI配置(、
.github/workflows/、.gitlab-ci.yml)Jenkinsfile - 查看是否有Docker或docker-compose配置(用于构建一致的测试环境)
- 确认是否存在 staging/预览环境URL模板
Phase 2: Interview (AskUserQuestion)
阶段2:访谈(询问用户问题)
Use AskUserQuestion to clarify requirements. Ask in rounds.
通过AskUserQuestion明确需求,分多轮进行。
Round 1: Scope and critical flows
第一轮:测试范围与关键流程
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 interactionsQuestion: "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问题: "需要测试哪些关键用户流程?"
标题: "流程"
多选: 是
选项:
- "认证(注册、登录、登出、密码重置)" — 核心认证流程
- "核心CRUD操作(创建、读取、更新、删除主要资源)" — 主要数据操作
- "结账/支付(购物车、账单、确认)" — 电商或支付流程
- "仪表盘/管理后台(数据视图、筛选、导出)" — 管理面板交互问题: "应用大约有多少个页面/路由?"
标题: "应用规模"
选项:
- "小型(<10个路由)" — 着陆页、认证页、少量功能页
- "中型(10-30个路由)" — 多个功能区域、设置页、个人资料页
- "大型(30+个路由)" — 包含多个板块与用户角色的复杂应用Round 2: Authentication strategy for tests
第二轮:测试认证策略
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问题: "你的应用采用何种认证方式?"
标题: "认证类型"
选项:
- "Cookie/会话认证(推荐)" — 登录后由服务器设置httpOnly Cookie
- "localStorage存储JWT" — 令牌存储在浏览器localStorage中
- "OAuth/单点登录(Google、GitHub等)" — 第三方认证提供商重定向流程
- "无认证(公开应用)" — 无需登录
问题: "测试应如何处理认证?"
标题: "测试认证方案"
选项:
- "通过UI登录一次,复用状态(推荐)" — storageState模式:在初始化流程中登录,跨测试共享Cookie
- "在beforeEach中调用API登录" — 每次测试前直接调用认证API,跳过UI登录
- "在fixture中注入预生成令牌" — 直接使用预生成的令牌,无需登录流程
- "每次测试都验证登录UI" — 在每个测试套件中实际测试登录表单Round 3: Test data and environment
第三轮:测试数据与环境
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问题: "应如何管理测试数据?"
标题: "测试数据"
选项:
- "在fixture中通过API预填充(推荐)" — 每次测试前调用API端点创建/清理测试数据
- "数据库预填充(直接SQL)" — 运行SQL脚本或ORM命令填充测试数据库
- "共享测试环境(已预填充数据)" — 测试在持久化的staging环境中运行,使用已有数据
- "Mock API响应" — 拦截网络请求并返回模拟数据
问题: "端到端测试在哪个环境运行?"
标题: "运行环境"
选项:
- "本地开发服务器(推荐)" — 测试前启动开发服务器,运行在localhost
- "预览/staging URL" — 在已部署的预览或staging环境中运行
- "Docker Compose栈" — 全栈运行在容器中,测试可在容器内外执行Round 4: CI and parallelization
第四轮: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问题: "测试应如何在CI中运行?"
标题: "CI配置"
选项:
- "GitHub Actions(推荐)" — Playwright原生支持分片执行
- "GitLab CI" — 基于Docker的运行器,使用Playwright镜像
- "仅本地运行(暂不接入CI)" — 目前仅在本地运行测试
- "其他CI(Jenkins、CircleCI)" — 自定义CI配置
问题: "是否需要视觉回归测试?"
标题: "视觉测试"
选项:
- "不需要 — 仅功能测试(推荐)" — 验证行为而非像素
- "需要 — 截图对比" — 捕获并对比页面截图
- "需要 — 组件截图" — 捕获特定组件而非整页Phase 3: Plan (ExitPlanMode)
阶段3:规划(退出规划模式)
Write a concrete implementation plan covering:
- Directory structure — test files, page objects, fixtures, config
- Playwright config — projects (browsers), base URL, retries, workers
- Auth setup — global setup for storageState or API-based auth
- Page objects — classes for each page with locators and actions
- Test fixtures — custom fixtures for data seeding, auth, API client
- Test suites — test files for each critical flow from the interview
- CI config — workflow file with sharding, artifact upload, reporting
Present via ExitPlanMode for user approval.
编写具体的实施计划,涵盖:
- 目录结构 — 测试文件、页面对象、fixture、配置文件
- Playwright配置 — 项目(浏览器)、基准URL、重试次数、并行线程数
- 认证设置 — 用于storageState或基于API的认证全局初始化
- 页面对象 — 每个页面对应的类,包含定位器与操作方法
- 测试fixture — 用于数据预填充、认证、API客户端的自定义fixture
- 测试套件 — 针对访谈中确定的每个关键流程编写测试文件
- CI配置 — 包含分片执行、工件上传、报告的工作流文件
通过ExitPlanMode提交计划,等待用户批准。
Phase 4: Execute
阶段4:执行
After approval, implement following this order:
获得批准后,按以下顺序实施:
Step 1: Playwright config
步骤1:Playwright配置
typescript
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,
},
});typescript
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,
},
});Step 2: Auth setup (global)
步骤2:全局认证设置
typescript
// 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 || 'test@example.com');
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 });
});typescript
// 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 });
});Step 3: Custom fixtures
步骤3:自定义Fixture
typescript
// 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 };typescript
// 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(`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 };Step 4: Page Object Model
步骤4:页面对象模型
typescript
// 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);
}
}typescript
// 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);
}
}Step 5: Test suites
步骤5:测试套件
typescript
// 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('test@example.com', 'testpassword');
await expect(page).toHaveURL('/dashboard');
});
test('invalid credentials shows error', async ({ loginPage }) => {
await loginPage.goto();
await loginPage.login('test@example.com', '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');
});
});typescript
// 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');
});
});Step 6: Visual regression (if selected)
步骤6:视觉回归测试(若选择)
typescript
// 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');
});
});typescript
// 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');
});
});Step 7: GitHub Actions CI
步骤7:GitHub Actions CI配置
yaml
undefinedyaml
undefined.github/workflows/e2e.yml
.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: 7undefinedname: 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: 7undefinedDirectory structure reference
目录结构参考
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 configuratione2e/
├── .auth/
│ └── user.json # 保存的认证状态(需在git中忽略)
├── fixtures.ts # 自定义测试fixture与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配置文件Best practices
最佳实践
Use role-based locators first
优先使用基于角色的定位器
Prefer , , over CSS selectors or test IDs. These locators mirror how users interact with the page and catch accessibility issues:
getByRole()getByLabel()getByText()typescript
// Preferred — accessible and resilient
await page.getByRole('button', { name: 'Submit' }).click();
await page.getByLabel('Email').fill('user@test.com');
// 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('user@test.com');优先使用、、而非CSS选择器或测试ID。这些定位器与用户和页面的交互方式一致,还能发现可访问性问题:
getByRole()getByLabel()getByText()typescript
// 推荐 — 可访问且稳定
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');Wait for network, not timers
等待网络状态,而非使用定时器
Never use . Wait for specific conditions:
page.waitForTimeout()typescript
// 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();永远不要使用。等待特定条件:
page.waitForTimeout()typescript
// 等待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();Isolate test data
隔离测试数据
Each test should create its own data and clean up after:
typescript
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)
});每个测试应创建自己的数据并在测试后清理:
typescript
test('edit resource', async ({ api, page }) => {
// 准备 — 通过API预填充数据
const resource = await api.createResource({ name: 'Test' });
// 执行
await page.goto(`/resources/${resource.id}`);
// ... 测试逻辑 ...
// 清理(也可通过afterEach在测试失败时执行)
});Tag tests for selective runs
为测试添加标签以支持选择性执行
typescript
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 @slowtypescript
test('checkout flow @slow @checkout', async ({ page }) => {
// 耗时较长的测试,添加标签用于选择性执行
});
// 仅运行指定标签的测试: npx playwright test --grep @checkout
// 跳过慢测试: npx playwright test --grep-invert @slow.gitignore additions
.gitignore需添加的内容
undefinedundefinedPlaywright
Playwright
e2e/.auth/
test-results/
playwright-report/
blob-report/
undefinede2e/.auth/
test-results/
playwright-report/
blob-report/
undefinedChecklist before finishing
完成前检查清单
- has webServer configured to start the dev server
playwright.config.ts - Auth setup saves storageState and all test projects depend on it
- Page objects use role-based locators (,
getByRole,getByLabel)getByText - No calls — only wait for elements, URLs, or responses
waitForTimeout() - Tests create and clean up their own data (no shared mutable state)
- CI config has sharding for parallel execution
- Trace, screenshot, and video are captured on failure for debugging
- directory is in
.auth/.gitignore - passes locally before pushing
npx playwright test
- 已配置webServer以启动开发服务器
playwright.config.ts - 认证设置已保存storageState,且所有测试项目依赖该初始化流程
- 页面对象使用基于角色的定位器(、
getByRole、getByLabel)getByText - 无调用 — 仅等待元素、URL或响应
waitForTimeout() - 测试自行创建并清理数据(无共享可变状态)
- CI配置包含分片执行以支持并行运行
- 测试失败时捕获Trace、截图与视频用于调试
- 目录已添加到
.auth/.gitignore - 本地运行通过后再推送代码
npx playwright test