Playwright端到端测试构建器Skill playwright-e2e-builder

这个技能用于自动化规划和构建Playwright端到端测试套件,支持页面对象模型、认证状态持久化、视觉回归测试和CI集成,通过访谈驱动方法优化测试流程。关键词:Playwright, E2E测试, 自动化测试, 测试套件, CI/CD, 页面对象模型, 视觉回归。

测试 0 次安装 0 次浏览 更新于 3/21/2026

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 devnext dev等)

应用结构

  • 识别主要路由/页面(查看路由器配置、页面目录或路由文件)
  • 查找认证流程(登录页面URL、认证API端点、令牌存储)
  • 检查组件中的测试ID(data-testiddata-testdata-cy属性)
  • 查找测试可能需要通过API路由播种数据的API
  • 检查.env文件以获取测试特定的环境变量

CI/CD

  • 检查现有CI配置(.github/workflows/.gitlab-ci.ymlJenkinsfile
  • 查找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:规划(退出规划模式)

编写具体的实施计划,涵盖:

  1. 目录结构 — 测试文件、页面对象、夹具、配置
  2. Playwright配置 — 项目(浏览器)、基础URL、重试次数、工作进程
  3. 认证设置 — 用于storageState或基于API认证的全局设置
  4. 页面对象 — 每个页面的类,包含定位器和操作
  5. 测试夹具 — 用于数据播种、认证、API客户端的自定义夹具
  6. 测试套件 — 每个关键流的测试文件,来自访谈
  7. 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,所有测试项目依赖它
  • [ ] 页面对象使用基于角色的定位器(getByRolegetByLabelgetByText
  • [ ] 无waitForTimeout()调用 — 仅等待元素、URL或响应
  • [ ] 测试创建并清理自己的数据(无共享可变状态)
  • [ ] CI配置有分片以并行执行
  • [ ] 跟踪、截图和视频在失败时捕获以供调试
  • [ ] .auth/目录在.gitignore
  • [ ] 推送前npx playwright test在本地通过