playwright-e2e-builder

Compare original and translation side by side

🇺🇸

Original

English
🇨🇳

Translation

Chinese

Playwright 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.ts
    ,
    @playwright/test
    in package.json)
  • 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
    ,
    next dev
    , etc.)
  • 确定技术栈:是React、Next.js、Vue、SvelteKit还是其他框架?
  • 检查是否已安装Playwright(查看
    playwright.config.ts
    、package.json中的
    @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-test
    ,
    data-cy
    attributes)
  • Look for API routes that tests might need to seed data through
  • Check
    .env
    files for test-specific environment variables
  • 识别主要路由/页面(查看路由配置、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 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
问题: "需要测试哪些关键用户流程?"
标题: "流程"
多选: 是
选项:
  - "认证(注册、登录、登出、密码重置)" — 核心认证流程
  - "核心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:
  1. Directory structure — test files, page objects, fixtures, config
  2. Playwright config — projects (browsers), base URL, retries, workers
  3. Auth setup — global setup for storageState or API-based auth
  4. Page objects — classes for each page with locators and actions
  5. Test fixtures — custom fixtures for data seeding, auth, API client
  6. Test suites — test files for each critical flow from the interview
  7. CI config — workflow file with sharding, artifact upload, reporting
Present via ExitPlanMode for user approval.
编写具体的实施计划,涵盖:
  1. 目录结构 — 测试文件、页面对象、fixture、配置文件
  2. Playwright配置 — 项目(浏览器)、基准URL、重试次数、并行线程数
  3. 认证设置 — 用于storageState或基于API的认证全局初始化
  4. 页面对象 — 每个页面对应的类,包含定位器与操作方法
  5. 测试fixture — 用于数据预填充、认证、API客户端的自定义fixture
  6. 测试套件 — 针对访谈中确定的每个关键流程编写测试文件
  7. 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
undefined
yaml
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: 7
undefined
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
undefined

Directory 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 configuration
e2e/
├── .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
getByRole()
,
getByLabel()
,
getByText()
over CSS selectors or test IDs. These locators mirror how users interact with the page and catch accessibility issues:
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');
优先使用
getByRole()
getByLabel()
getByText()
而非CSS选择器或测试ID。这些定位器与用户和页面的交互方式一致,还能发现可访问性问题:
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
page.waitForTimeout()
. Wait for specific conditions:
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 @slow
typescript
test('checkout flow @slow @checkout', async ({ page }) => {
  // 耗时较长的测试,添加标签用于选择性执行
});

// 仅运行指定标签的测试: npx playwright test --grep @checkout
// 跳过慢测试: npx playwright test --grep-invert @slow

.gitignore additions

.gitignore需添加的内容

undefined
undefined

Playwright

Playwright

e2e/.auth/ test-results/ playwright-report/ blob-report/
undefined
e2e/.auth/ test-results/ playwright-report/ blob-report/
undefined

Checklist before finishing

完成前检查清单

  • playwright.config.ts
    has webServer configured to start the dev server
  • Auth setup saves storageState and all test projects depend on it
  • Page objects use role-based locators (
    getByRole
    ,
    getByLabel
    ,
    getByText
    )
  • No
    waitForTimeout()
    calls — only wait for elements, URLs, or responses
  • 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
  • .auth/
    directory is in
    .gitignore
  • npx playwright test
    passes locally before pushing
  • playwright.config.ts
    已配置webServer以启动开发服务器
  • 认证设置已保存storageState,且所有测试项目依赖该初始化流程
  • 页面对象使用基于角色的定位器(
    getByRole
    getByLabel
    getByText
  • waitForTimeout()
    调用 — 仅等待元素、URL或响应
  • 测试自行创建并清理数据(无共享可变状态)
  • CI配置包含分片执行以支持并行运行
  • 测试失败时捕获Trace、截图与视频用于调试
  • .auth/
    目录已添加到
    .gitignore
  • 本地运行
    npx playwright test
    通过后再推送代码