mcpField
P
playwright-e2e-testing

playwright-e2e-testing

github.com/perdolique测试与质量047 次安装2 天前更新
代码仓库

关于 playwright-e2e-testing

playwright-e2e-testing 是一套 Playwright 浏览器、端到端及 API 测试指导 Skill,面向编写和排查测试的开发者。它涵盖 SPA 导航、请求模拟、异步等待和用户可见回归证据等实践。

主要功能

  1. 01选择合适的测试边界
  2. 02组织测试目录与共享夹具
  3. 03等待异步 UI 状态
  4. 04使用面向用户的定位器
  5. 05模拟 API 请求与响应
  6. 06验证用户可见回归行为

适用场景

  1. 01编写浏览器和端到端流程测试
  2. 02测试 SPA 路由与重定向
  3. 03直接测试 API 端点
  4. 04排查 Playwright 测试失败

安装

复制下面的命令,在终端中运行。

$npx skills add https://github.com/perdolique/workflow --skill playwright-e2e-testing

安装后在 Claude Code 中输入/playwright-e2e-testing确认已加载。

包含的文件

除 SKILL.md 外,安装时一起下载的参考资料和脚本。

evals/
evals.json
evals/files/
performance.md
verification.md
evals/files/harness/
context.md
global.fixtures.ts
evals/files/regressions/
icon.html
icon.spec.ts
polling.html
polling.spec.ts
responsive.html
responsive.spec.ts
search.html
search.spec.ts
widget.html
widget.spec.ts
references/
configuration.md
fixtures.md
performance.md

详细介绍

使用 Playwright 进行浏览器、E2E 和 API 测试

本技能提供 Playwright 浏览器、E2E 和直接 API 测试的模式与约定,包括针对 SPA 的路由、API 模拟和异步导航技巧。

何时使用此技能

  • 为用户流程编写浏览器或 E2E 测试
  • 测试页面路由和重定向
  • 测试对话框/模态框交互
  • 通过 Playwright 的 APIRequestContext 直接测试端点
  • 为 Playwright 测试创建模拟、fixture 和断言
  • 调试失败的 Playwright 浏览器、E2E 或 API 测试
  • 根据任务和项目已采用的测试工具选择测试技能。根据相应上下文处理对模拟或测试数据的通用请求。

项目配置

设置或修改 playwright.config.ts 时,请参阅 references/configuration.md,了解 Web 服务设置、报告器、跟踪、屏幕截图和 CI 行为方面的约定。

选择测试边界

  • 明确说明要测试的浏览器行为,以及测试应捕获的可能故障。先检查现有覆盖范围。纯计算应放在项目的单元测试中,直接 HTTP 契约应放在 API 测试中;浏览器测试则用于渲染、交互、请求连接、导航和浏览器状态。
  • 如果在现有流程中添加断言即可保护同一场景,且无需再进行一次完整的设置,就应添加断言。若要保护的是不同故障,或需要不同状态,则应使用单独的用例。模拟不要覆盖测试声称要验证的决策。

测试导入

检查项目的测试目录,查找扩展了 Playwright test 对象的自定义 fixture 文件,通常用于 API 拦截或共享设置。如果存在,请从该文件导入 test 和 expect,以便测试使用项目的设置:

import { test, expect } from '../fixtures/global.fixtures.ts';

如果项目没有自定义 fixture,请从 Playwright 导入:

import { test, expect } from '@playwright/test';

始终可以直接导入独立类型:

import type { Page, Locator } from '@playwright/test';

配置共享浏览器 fixture,使其在应用出现 pageerror、console.error、水合警告和未解析组件时失败。复用项目的 fixture 时,保留这些检查。仅排除测试会显式断言的浏览器生成资源错误。

每项测试都使用提供的 page 和 context,以确保浏览器状态全新。只有在测试之间不会互相更改状态时,才共享不可变数据或设置资源。页面或上下文销毁前,应完成异步事件处理程序,即使测试失败也不例外。对于自有的控制台监听器,在 fixture 清理过程中调用 await page.removeAllListeners('console', { behavior: 'wait' }),可以等待已注册的处理程序完成。在处理程序内部处理每次读取载荷的失败,以免某次拒绝导致处理程序在其他读取仍在等待时结束。保留成功读取的参数值和原始读取错误,并在所有自有读取完成后报告。

目录结构

按功能领域组织测试。每个领域通常都有自己的 fixture 文件:

tests/playwright/
├── constants.ts                     # Base URLs, shared constants
├── fixtures/
│   ├── global.fixtures.ts           # Extended test/expect (if project uses one)
│   ├── core-api.fixtures.ts         # Shared API mock helpers
│   └── {feature}.fixtures.ts        # Feature-specific test data
├── {feature-domain}/
│   └── {feature}.test.ts            # Test file
└── routing/
    └── redirects.test.ts            # Router/redirect tests

编写测试文件

基本结构
import type { Page } from '@playwright/test';
import { test, expect } from '../fixtures/global.fixtures.ts';
import { mockEndpoint } from '../fixtures/api.fixtures.ts';
import { baseFixture, variantFixture } from '../fixtures/{feature}.fixtures.ts';

// Helper functions (navigation, assertions)
async function openFeaturePage(page: Page): Promise<void> {
  await page.goto('/feature?id=test-id');
}

test.describe('Feature name', () => {
  test('description of expected behavior', async ({ page }) => {
    await mockEndpoint(page, baseFixture);
    await openFeaturePage(page);
    await expect(page.getByText('Expected text')).toBeVisible();
  });
});
辅助函数

在测试文件顶部定义辅助函数,用于执行重复操作。辅助函数可提升可读性并减少重复:

  • 导航辅助函数 — openDialog(page)、gotoProductPage(page)
  • 断言辅助函数 — expectSummary(page, options)、expectAvailability(page, from, to)
  • 设置辅助函数 — setupCommonMocks(page),用于创建特定测试的模拟组合
  • 定位器辅助函数 — actionButtons(page),返回一个 Locator
使用 beforeEach 进行共享设置

当 describe 块中的所有测试共享相同的模拟设置时:

test.describe('Router redirects', () => {
  test.beforeEach(async ({ page }) => {
    await setupCommonMocks(page);
  });

  test('redirects when resource is not found', async ({ page }) => {
    await page.goto('/resource?id=unknown');
    await expect(page).toHaveURL(/\/error\?id=unknown$/u);
  });
});
时序与布局
  • 使用 Playwright 断言等待相关 UI 状态。固定等待时间和 networkidle 都无法证明待测元素或数据已就绪。
  • 对于轮询或防抖行为,应在应用计时器启动前安装 page.clock,通常是在导航前。当场景需要执行每个计时器回调时,使用 runFor;只有在跳过时间且减少回调次数符合待测行为时,才使用 fastForward。
  • 在触发操作或推进时钟前,注册请求或响应等待。推进时间,等待相关 HTTP 结果,并断言可见结果。保持应用的轮询、重新加载和状态变化按真实逻辑运行;更新模拟响应,而不是调用产品内部逻辑或直接设置最终 DOM 状态。
  • 测量布局时,应等待相关字体加载完成且有限时长的元素动画结束。如果动画或过渡时序是测试约定的一部分,则保持动画启用,并观察所需的中间状态和最终状态。浏览器时钟不能替代 CSS 动画或服务器时间证据。
使用 test.step() 编写多步骤流程

对于购买、表单提交等复杂用户流程中的重要阶段,使用命名的 test.step() 代码块。这样,报告就能指出失败的阶段。除非项目有更具体的惯例,否则简短、连贯的测试应直接按顺序编写操作和断言。

test('completes purchase flow', async ({ page }) => {
  await test.step('prepare mocks', async () => {
    await mockProduct(page, productFixture);
    await mockCheckout(page);
  });

  const purchaseRequestPromise = page.waitForRequest(
    request => request.url().includes('/purchases') && request.method() === 'POST'
  );

  await test.step('open product page', async () => {
    await gotoProductPage(page);
  });

  await test.step('select option', async () => {
    await page.getByRole('button', { name: '14:00' }).click();
  });

  await test.step('verify confirmation page', async () => {
    await expect.poll(() => new URL(page.url()).pathname).toBe('/order-confirmation');
  });

  await test.step('submit order', async () => {
    await page.getByRole('button', { name: 'Confirm' }).click();
  });

  await test.step('verify purchase request', async () => {
    const request = await purchaseRequestPromise;

    expect(request.postDataJSON()).toEqual({
      items: [{ id: 'product-1', date: '2088-04-21T11:00:00.000Z' }]
    });
  });
});

多步骤流程中的关键模式:

  • page.waitForRequest() — 在触发请求的操作之前设置
  • expect.poll() — 在 SPA 导航后等待异步 URL 变化
  • satisfies — 对请求负载预期进行类型检查,同时保留字面量类型
通过 addInitScript 设置 localStorage

要在页面加载前设置 localStorage 值(例如,为路由测试保存状态):

async function setSavedState(page: Page, key: string, value: unknown): Promise<void> {
  await page.addInitScript(
    ({ storageKey, data }: { storageKey: string; data: string }) => {
      localStorage.setItem(storageKey, data);
    },
    { storageKey: key, data: JSON.stringify(value) }
  );
}

addInitScript 会在页面加载前运行,因此应用会在初始化时读取正确的 localStorage 值。page.evaluate 会在页面加载后运行,对于路由守卫来说太迟了。

选择器模式

优先按以下顺序使用面向用户的选择器:

  1. page.getByRole('button', { name: 'Confirm' }) — 无障碍角色 + 名称
  2. page.getByText('Expected text') — 可见文本内容
  3. page.getByTestId('action-button') — data-testid 属性
  4. page.getByText('text', { exact: true }) — 精确匹配,避免部分匹配
  5. page.getByText(/regex pattern/u) — 用于动态内容的正则表达式

通过无障碍角色和名称、相关容器、行或卡片以及可见性,将定位器细化到目标元素。当位置本身属于待测行为,或测试已有充分理由按位置选择等效匹配项时,使用 .first() 或 .nth()。

断言模式

// Visibility
await expect(page.getByText('Welcome')).toBeVisible();

// Element count
await expect(page.getByRole('button', { name: /\d{2}:\d{2}/u })).toHaveCount(3);
await expect(page.getByText('Not present')).toHaveCount(0); // Assert absence

// URL matching
await expect(page).toHaveURL('http://localhost:5050/error?id=unknown');
await expect(page).toHaveURL(/\/order-success\?id=test-id$/u);

// Async URL change (SPA navigation)
await expect.poll(() => new URL(page.url()).pathname).toBe('/order-confirmation');

// Interactive state
await expect(page.getByRole('button', { name: 'Submit' })).toBeEnabled();
await expect(page.getByRole('button', { name: 'Submit' })).toBeDisabled();

// Unicode-aware regex with explicit apostrophe variants
await expect(page.getByText(/^You(?:'|’|ʼ)re all set!$/u)).toBeVisible();

始终使用 /u(Unicode)标志,以便正则表达式在 Unicode 感知模式下解析。如果文本中可能包含不同形式的撇号或其他字符变体,应在模式中明确列出可接受的变体。

面向用户的回归证据

  • 选择断言之前,先定义需要保护的可观察行为。
  • 使用相关的指针和键盘输入,测试每一种有实质变化的状态和转换。
  • 如果受保护的行为被移除或绕过,回归测试必须失败。始终通过的测试无法保护该约定。
  • 断言行为所需的渲染内容和几何布局。仅检查包装元素存在或 toBeVisible() 仍可能通过,即使图标为空、子元素被裁切,或内容位于视口之外。
  • 对于状态转换,断言相关的顺序、时机、焦点和稳定内容。仅检查最终状态,无法证明用户没有遇到闪烁、过时结果或焦点丢失。
  • 对于响应式行为,选择能揭示不同故障的视口:检查溢出时使用支持的最小宽度,检查桌面布局时使用常见桌面宽度;如果行为因断点而异,则检查断点两侧。若容器响应式组件在窄、宽容器中存在不同风险,应在不改变视口的情况下分别测试。
  • 当较长内容可能影响受保护的布局时,使用真实且较长的内容,以及不间断的长文本。检查文档和相关子元素的矩形;仅检查根元素的 scrollWidth,无法证明子元素可见或位于视口内。
  • 断言范围应与约定一样精确。若只关心某个组件状态,不要用整页快照替代有针对性的证据。

使用 page.route() 模拟 API

基本模拟
async function mockProducts(page: Page, response: unknown): Promise<void> {
  await page.route('**/api/products**', async (route) => {
    await route.fulfill({ json: response });
  });
}
模拟创建并轮询流程
async function mockPurchase(page: Page, purchaseId: string, response: unknown): Promise<void> {
  // Mock POST (create)
  await page.route('**/api/purchases', async (route) => {
    await route.fulfill({ json: { id: purchaseId } });
  });

  // Mock GET (status poll)
  await page.route(`**/api/purchases/${purchaseId}`, async (route) => {
    await route.fulfill({ json: response });
  });
}
控制无关的外部请求

根据场景和项目的网络策略选择网络边界。复用现有模拟,只覆盖场景所需的响应。使用 glob 或正则表达式,既可避免本地资源出现在来源限制器的拦截消息中,也可将其排除在处理器之外:

const appOrigin = new URL(appBaseUrl).origin;
const escapedAppOrigin = appOrigin.replaceAll(/[.*+?^${}()|[\]\\]/gu, String.raw`\$&`);
const externalRequests = new RegExp(`^(?!(?:blob:)?${escapedAppOrigin}(?:/|$))`, 'u');

await page.route(externalRequests, async (route) => {
  await route.abort();
});

如果场景要测试外部集成本身,请使用配置好的测试环境,并保持该交互为真实交互。模拟被测行为之外的依赖项。

让每个模拟都重现与场景相关的生命周期,包括适用时的完成、过期、取消、重试或一次性值。不要让模拟产生真实依赖项无法产生的状态。如果无法安全地进行真实模拟,请使用官方测试模式,或将该行为标记为未经验证。

覆盖现有模拟

Playwright 的路由匹配采用后注册者优先。为相同模式注册新路由,即可覆盖较早的模拟:

// Global fixture mocks /api/properties with defaults
// Your test overrides with custom data:
await page.route('**/api/properties/**', async (route) => {
  await route.fulfill({ json: customPropertyData });
});
验证请求取消

当取消行为很重要时,先挂起过时响应,在触发替代请求前注册 requestfailed,断言请求已中止(在 Chromium 中为 ERR_ABORTED),然后释放模拟并验证只渲染当前数据。最终 UI 本身无法证明传输已取消;绝不要完成已中止的路由。

模拟第三方依赖项
async function setupExternalMocks(page: Page): Promise<void> {
  await page.route('https://analytics.example.com/**', async (route) => {
    await route.fulfill({ status: 200, body: '' });
  });
}

创建测试数据夹具

创建或修改夹具数据时,请阅读夹具指南。其中涵盖基础对象和变体、私有工厂、字面量类型、现有约定、场景日期和模拟辅助工具。

API 请求测试

如需直接测试服务端 API 端点,请使用 Playwright 的 APIRequestContext,无需浏览器。

用于身份验证的 Worker 作用域夹具

如果会话和服务器数据可以安全共享,请使用 Worker 作用域夹具进行 API 身份验证。夹具负责设置和清理,但其 APIRequestContext 仍会携带可变的 Cookie。对于会更改共享身份验证或服务器状态的测试,请使用单独的上下文或身份。

// tests/playwright/fixtures.ts
import { test as base, type APIRequestContext } from '@playwright/test'
import { appBaseUrl } from './constants'

interface WorkerFixtures {
  authedRequest: APIRequestContext;
}

export const test = base.extend<Record<never, never>, WorkerFixtures>({
  authedRequest: [
    async ({ playwright }, use) => {
      const request = await playwright.request.newContext({ baseURL: appBaseUrl })
      await request.post('/api/auth/create-session')
      await use(request)
      await request.dispose()
    },
    { scope: 'worker' },
  ],
})

在 API 测试文件中从此文件导入 test。在测试回调中,可通过 { authedRequest } 使用该夹具。

类型安全的 JSON 解析

APIResponse.json() 返回 Promise<any>(Playwright 的 Serializable = any)。测试读取 JSON 正文时,请使用 unknown 作为中间类型,并用 Valibot 解析:

const response = await request.get('/api/items')
const raw: unknown = await response.json()
const body = v.parse(mySchema, raw)  // Valibot accepts unknown, returns typed result

Valibot 的 parse 会检查运行时结构并返回类型化数据。将请求、JSON 读取和解析分别放在单独的行上,以便清楚地定位失败。只检查状态、标头或正文缺失的测试,应使用相应的响应断言,而不读取 JSON。

API 测试中的断言

Valibot 验证响应结构后,应重点断言 HTTP 契约和被测行为。仅当架构已检查相同要求时,才移除结构断言:

// Redundant when v.parse(schema, raw) already validates this shape
expect(body).toMatchObject({ id: expect.any(Number), name: expect.any(String) })

// Tests the HTTP contract, correct data, filter logic, and auth behavior
expect(response.status()).toBe(200)
expect(body.name).toBe('MSR')
expect(body.items.length).toBeGreaterThan(0)
expect(filteredBody.items.every(item => item.category.slug === 'sleeping-pads')).toBe(true)

高价值的 API 断言:状态码、具体值(用于确认记录正确的名称、slug、ID)、筛选正确性、分页边界、身份验证执行情况(无会话时返回 401)。

测试 401 / 未通过身份验证的响应

内联创建匿名上下文——切勿在身份验证失败测试中复用已通过身份验证的 fixture:

test('returns 401 without session cookie', async ({ playwright }) => {
  const anonRequest = await playwright.request.newContext({ baseURL: appBaseUrl })
  const response = await anonRequest.get('/api/equipment/groups')

  expect(response.status()).toBe(401)

  await anonRequest.dispose()
})
# Run all Playwright tests
vpx playwright test

# Run a specific test file
vpx playwright test tests/playwright/product/checkout.test.ts

# Run with UI mode for debugging
vpx playwright test --ui

# Run with headed browser
vpx playwright test --headed

# Run with Playwright inspector/debug mode
vpx playwright test --debug

# Open the last HTML report
vpx playwright show-report

检查项目的 package.json,确认可用的测试脚本——许多项目会定义诸如 test:playwright、test:e2e 或类似的快捷脚本。

在项目相关的浏览器项目中运行已更改的浏览器场景,并禁用重试。确认服务器提供的是当前检出版本构建的内容;有关服务器复用和失败跟踪,请参阅配置指南。如果任务涉及测试套件运行时间、fixture 开销、worker 或分片,请阅读性能指南。普通测试更改无需反复进行全套基准测试。

验证清单

在完成 E2E 测试之前,请确认:

  • 如果项目存在 fixture 文件,则从该文件导入 test 和 expect;否则从 @playwright/test 导入
  • 诸如 Page 和 Locator 的 Playwright 类型,均通过 import type 直接导入
  • 特定功能的 fixture 对所有导出的对象使用 as const
  • 具有已知项目契约的 fixture 使用兼容的 satisfies 检查;有意设为无效的 fixture 在其对应场景中仍保持无效
  • fixture 日期代表预期状态;如果行为取决于当前时间或边界,则控制时间
  • test.describe() 对相关测试进行分组
  • 对重复的导航/断言模式提取辅助函数
  • 复杂流程中的重要阶段使用 test.step() 块;简短且连贯的测试保持直接
  • 在触发操作之前设置 page.waitForRequest()
  • 正则表达式使用 /u 标志
  • 定位器以语义方式识别目标元素;.first() 或 .nth() 表示有意的位置选择
  • 根据项目网络策略控制被测行为之外的依赖;被测集成在配置的测试环境中保持真实
  • 读取 JSON 的 API 测试:正文以 unknown 类型接收,并由 Valibot 验证;仅移除与架构重复的结构断言
  • 不读取 JSON 的 API 测试:状态、标头和正文缺失情况使用相应的响应断言,不添加不必要的架构
  • API 测试:身份验证测试使用内联匿名上下文,而非已通过身份验证的 fixture
  • API 测试:身份验证使用由 fixture 管理、作用域可保留独立状态的上下文
  • 测试成功运行

参考文件

评论

支持 Markdown