mcpField
F
front-end-testing

front-end-testing

github.com/citypaul测试与质量759246 次安装4 周前更新
代码仓库

关于 front-end-testing

用于规划和评估前端测试的技能,涵盖用户可见行为、浏览器测试及测试证据的边界说明,适合处理不同测试环境下 UI 测试的 Agent。

主要功能

  1. 01优先验证用户可见行为
  2. 02比较 jsdom 与浏览器测试
  3. 03使用 Vitest Browser Mode
  4. 04指导 Playwright E2E 测试
  5. 05说明测试证据的边界

适用场景

  1. 01为依赖浏览器的行为选择测试方式
  2. 02围绕用户交互编写 UI 测试
  3. 03说明测试通过不能证明的内容

安装

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

$npx skills add https://github.com/citypaul/.dotfiles --skill front-end-testing

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

包含的文件

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

resources/
async-patterns.md
dom-testing-library-legacy.md
msw.md
playwright-e2e.md

详细介绍

前端测试

对于 React 特定模式(组件、钩子、上下文),请加载 react-testing 技能。对于 TDD 工作流,请加载 tdd 技能。对于通用测试模式(工厂函数、公共接口测试),请加载 testing 技能。

每次交接都要说明测试框架及其证据的边界。 每当你报告 UI 测试工作已完成时——无论是在回复、PR 正文还是 CI 步骤标签中——都要说明生成证据的运行器和环境(针对已启动应用运行的 Playwright、在 jsdom 中运行的 Vitest、真实浏览器中的 Browser Mode),并同时说明该证据最接近但无法证明的内容。jsdom 测试套件无法证明真实渲染、CSS、焦点或浏览器自身的事件分发;组件级测试套件无法证明已启动的应用、应用路由或真实服务器;一次用户旅程无法证明它未走过的路径。只说“所有测试均通过”却不说明边界,会让人觉得结论比测试实际支持的更有把握。

深入学习资源位于 resources/ 目录中。按需加载:

资源加载时机……
resources/playwright-e2e.md针对运行中的应用编写或审查 Playwright Test 端到端/用户旅程测试套件时——谁可以发起请求、如何在不发起请求的情况下观察网络、直接传输审查、身份验证/生命周期证据
resources/async-patterns.md使用 findBy/waitFor/waitForElementToBeRemoved、测试加载状态或防抖,或审查 waitFor 用法时
resources/msw.md模拟 API 时——完整的 setupWorker(Browser Mode)和 setupServer(Node/jsdom)配置、逐测试覆盖
resources/dom-testing-library-legacy.md在 jsdom/@testing-library/dom 代码库中工作时——screen 对象、fireEvent 与 userEvent、jest-dom 匹配器、ESLint 插件

核心理念

测试用户可见的行为,而不是实现细节。 这适用于所有环境——Browser Mode、jsdom 或其他任何环境。

你的 UI 有两类用户:

  1. 最终用户:通过 DOM 交互(点击、输入、阅读文本)
  2. 开发者:重构实现的你

Kent C. Dodds 原则:“测试越像软件的实际使用方式,就越能让你对软件充满信心。”

假阴性(重构时测试失败):

// ❌ WRONG - Coupled to state implementation; breaks when state → signals → stores
it('should update internal state', () => {
  const component = new CounterComponent();
  component.setState({ count: 5 });
  expect(component.state.count).toBe(5);
});

假阳性(有缺陷却通过测试):

// ❌ WRONG - Button exists but onClick is broken; test still passes
it('should render button', () => {
  render('<button data-testid="submit-btn">Submit</button>');
  expect(screen.getByTestId('submit-btn')).toBeInTheDocument();
});

✅ 正确做法——像用户一样操作 UI,并断言用户看到的内容:在带标签的字段中输入内容,点击提交按钮,并断言提交处理函数收到了表单数据。这种方式经得起重构,测试了约定,也能发现真正的错误(例如 onClick 失效、校验错误)。


使用 Vitest Browser Mode 测试浏览器可观察行为

当测试结论依赖真实渲染、CSS、事件、焦点管理、无障碍功能或浏览器 API,且仓库已支持 Browser Mode,或新增测试框架的成本合理时,优先使用 Vitest Browser Mode。如果现有 jsdom/happy-dom 测试框架稳定,或更轻量的环境足以验证纯逻辑或组件约定,且不涉及浏览器特定行为,就继续使用现有框架或轻量环境。

为什么选择 Browser Mode,而不是 jsdom
方面jsdom/happy-domBrowser Mode
环境Node.js 中的模拟 DOM真实浏览器(Chromium/Firefox/WebKit)
CSS不渲染真实 CSS 渲染、布局、计算样式
事件合成 JS 事件基于 CDP 的真实浏览器事件
API部分 Web API完整的浏览器 API
焦点/无障碍近似模拟真实焦点管理、无障碍树
调试仅控制台完整的浏览器开发者工具
设置

在更改依赖项或配置之前,检查仓库的包管理器、锁文件、现有测试框架和已安装版本。如果有充分理由引入新的测试框架,且用户已授权进行设置,则根据官方兼容性/对等依赖证据选择确切且彼此兼容的版本,使用仓库的包管理器安装,并且只调用仓库本地二进制文件(不进行隐式下载)。例如:

<repo-pm> add --save-dev vitest@<reviewed-version> @vitest/browser-playwright@<reviewed-version>
<repo-pm> exec playwright install chromium  # inspect this binary download before authorizing it
// vitest.config.ts
import { defineConfig } from 'vitest/config'
import { playwright } from '@vitest/browser-playwright'

export default defineConfig({
  test: {
    browser: {
      enabled: true,
      provider: playwright(),
      headless: true,
      instances: [{ browser: 'chromium' }],
    },
  },
})

如果已安装版本支持,请使用 <repo-pm> exec vitest init browser 运行本地设置向导;先检查它计划写入的配置。

聚焦浏览器测试反馈

将 tdd 技能的快速反馈策略同样应用于浏览器测试:

  • Vitest Browser Mode 遵循 tdd 技能及其 Vitest watch 参考 中关于仓库所有与差异选定策略、生命周期、清理、本机发现和实时证明的相同规则。Node/SSR 4.1.10 的干净启动证明不能证明 Browser Mode;请单独核实其已安装版本/配置。不要假设 --changed --watch 会在启动后重新加载 VCS 影响范围。
  • 浏览器依赖通常位于导入图之外。对于路由、全局样式、浏览器设置、模板、生成的资源、仅类型关系、浏览器模式编排和 Docker 支持的服务,应扩大测试范围。保留仓库的 watchTriggerPatterns、forceRerunTriggers 以及根目录 monorepo 项目/任务图。
  • Playwright Test 提供原生的一次性受影响测试功能:如果已安装版本支持,GREEN/REFACTOR 阶段优先使用仓库脚本或 playwright test --only-changed[=<real-base>]。它会选择已更改的测试文件,以及导入已更改文件的测试,但 Playwright 将其说明为一种启发式方法。浏览器旅程通常在运行时而非通过导入来调用应用代码,因此动态/非导入依赖可能不可见。对于这类更改,请使用仓库映射出的受影响旅程/项目集合,其中应包含共享 fixture、身份验证状态、设置、路由、样式和 UI 包的所有使用方;不确定时应扩大范围。
  • 只有在证明 RED 或调试已知故障时,才使用 Playwright 文件、--grep、--last-failed 或手动选择的项目筛选器。对于 GREEN/REFACTOR,只有当运行器/工作区图以机制化方式推导出完整的受影响项目集合(包括传递使用方)时,项目筛选器才适用。请确认 --only-changed 确实执行了预期测试;没有选中任何测试不能作为 GREEN 证据。
  • 使用一次性运行器时,创建测试后要重新运行受影响的命令。不要将空的受影响集合、Vitest --passWithNoTests 或 Playwright --pass-with-no-tests 视为证据。
  • PR 准备就绪时,停止监视器,并按照目标仓库的变更策略以及完整的非监视测试门禁执行。该门禁必须涵盖所有已配置浏览器/项目的完整 UI 测试矩阵;仅针对 Chromium 的定向测试、--grep 或仅 UI 的证据都不充分。
内置定位器

Vitest Browser Mode 内置了与 Testing Library 查询相对应的定位器。无需单独导入 @testing-library/dom。

import { page } from 'vitest/browser'

// These work exactly like Testing Library queries
page.getByRole('button', { name: /submit/i })
page.getByText(/welcome/i)
page.getByLabelText(/email/i)
page.getByPlaceholder(/search/i)
page.getByAltText(/logo/i)
page.getByTestId('my-element')  // Last resort only
支持重试的内置断言

对 DOM 断言使用 expect.element()——它会自动重试,直到断言通过或超时,从而减少测试不稳定:

// ✅ CORRECT - Auto-retrying assertion
await expect.element(page.getByText(/success/i)).toBeVisible()
await expect.element(page.getByRole('button')).toBeDisabled()

// Available matchers (no @testing-library/jest-dom needed):
await expect.element(el).toBeVisible()
await expect.element(el).toBeDisabled()
await expect.element(el).toHaveTextContent(/text/i)
await expect.element(el).toHaveValue('input value')
await expect.element(el).toHaveAttribute('aria-label', 'Close')
await expect.element(el).toBeChecked()
内置用户事件(基于 CDP)
import { userEvent } from 'vitest/browser'

// Real browser events via Chrome DevTools Protocol
await userEvent.click(page.getByRole('button', { name: /submit/i }))
await userEvent.fill(page.getByLabelText(/email/i), '[email protected]')
await userEvent.keyboard('{Enter}')
await userEvent.selectOptions(page.getByLabelText(/country/i), 'USA')
await userEvent.clear(page.getByLabelText(/search/i))

或者直接使用定位器方法:

await page.getByRole('button', { name: /submit/i }).click()
await page.getByLabelText(/email/i).fill('[email protected]')

在 jsdom 代码库中,请改用 @testing-library/user-event;在用户交互中优先使用它,而非 fireEvent(参见 resources/dom-testing-library-legacy.md)。默认情况下,每个测试都应创建新的 userEvent.setup()。如果隔离的 beforeEach 会为每个非并发测试创建新实例,也可以使用;切勿在整个测试套件中重复使用同一个用户实例。

多项目设置(Node + 浏览器)

当你需要同时运行单元测试(Node)和 UI 测试(浏览器)时:

export default defineConfig({
  test: {
    projects: [
      {
        test: {
          include: ['tests/unit/**/*.test.ts'],
          name: 'unit',
          environment: 'node',
        },
      },
      {
        test: {
          include: ['tests/browser/**/*.test.ts'],
          name: 'browser',
          browser: {
            enabled: true,
            provider: playwright(),
            instances: [{ browser: 'chromium' }],
          },
        },
      },
    ],
  },
})
浏览器模式注意事项
  • 导入项上的 vi.spyOn:在真实浏览器中,ES 模块命名空间是封闭的。vi.mock('./module', { spy: true }) 可以使用,但应将模块模拟视为临时脚手架——优先采用参数注入,让依赖成为明确的接缝(加载 finding-seams 技能)。对于会发起网络请求的模块,这绝不是解决方案:应在网络层使用 MSW 模拟,并在所有环境中以及错误路径和正常路径上都进行模拟。
  • alert()/confirm():会阻塞线程的对话框会暂停浏览器执行。使用 vi.spyOn(window, 'alert').mockImplementation(() => {}) 模拟它们。
  • act():通过定位器进行组件交互时不需要它——CDP 事件和 expect.element() 重试会处理时序问题。renderHook 的状态更新仍需要 act(参见 react-testing)。
Playwright / 浏览器模式测试的幂等性

所有 Playwright 风格的测试都必须具有幂等性。 每项测试无论执行顺序如何、运行多少次,或之前运行过哪些其他测试,都必须产生相同的结果。

规则:

  • 每项测试都从头创建自己的状态——绝不依赖其他测试的副作用
  • 清理测试期间创建的任何持久状态(数据库行、localStorage、Cookie)
  • 使用熵足够大的标识符以支持并行运行(例如 crypto.randomUUID());仅使用时间戳可能会发生冲突
  • 绝不假设测试开始时 DOM 处于特定状态——应重新渲染
  • 如果测试共用服务或数据库,请采用隔离策略(事务、测试专用数据)
// ❌ WRONG - Tests depend on shared state
it('creates a user', async () => {
  await page.getByRole('button', { name: /create/i }).click()
  // Creates user "Alice" in the database
})

it('lists users', async () => {
  // Assumes "Alice" exists from previous test!
  await expect.element(page.getByText('Alice')).toBeVisible()
})

// ✅ CORRECT - Each test is self-contained
it('creates and displays a user', async () => {
  const uniqueName = `User-${crypto.randomUUID()}`
  try {
    await page.getByLabelText(/name/i).fill(uniqueName)
    await page.getByRole('button', { name: /create/i }).click()
    await expect.element(page.getByText(uniqueName)).toBeVisible()
  } finally {
    await testData.deleteUserByName(uniqueName) // repository-owned idempotent cleanup fixture
  }
})

**为什么这很重要:**浏览器模式可以在多个浏览器实例间并行运行测试。非幂等测试会导致不稳定的失败,几乎无法调试。


Playwright E2E 是另一个主题

Vitest 浏览器模式测试的是独立运行的组件;针对运行中应用的 Playwright Test 测试的是测试声明所指的内容——用户旅程、前端自身的网络行为、Cookie/CSRF 安全态势、重定向或渲染。它们使用相同的浏览器引擎,但测试对象和测试框架不同:切勿假设两者的指导原则可以互相套用。

一项断言,一个测试框架。 使用能够在断言为假时失败的最轻量测试框架来验证断言,到此为止。单个已挂载组件内的行为——错误修复、错误路径、必须能够恢复的禁用按钮——应由组件级测试框架验证;再添加一个 E2E 规格来重复检查这些行为,不会带来更多证据,只会增加需要维护的第二套测试,并拖慢质量门禁。当断言本身针对所服务的应用时,才使用 Playwright:例如导航、依次经过多个屏幕、真实服务、Cookie 或重定向。如果你已经编写了组件测试,那么添加用户旅程测试就应有明确的理由。

E2E 套件遵循一条总规则:浏览器或用户旅程的断言必须由浏览器发起者验证——使用可访问的定位器执行操作或进行导航,绝不能用直接 HTTP 调用代替用户或前端。在名为“用户创建……”的测试中使用 page.request.post('/api/...'),验证的是 HTTP 契约,而非用户旅程;即使按钮、Cookie 策略、CSRF 检查、重定向或渲染出现故障,测试仍会通过。编写或审查任何 E2E / 用户旅程测试套件前,请先加载 resources/playwright-e2e.md——其中包含决策规则、证据边界表、安全的请求观测方法、直接传输审计流程,以及身份验证 / 生命周期证据契约。


查询选择优先级

最关键的技能:选择正确的查询。 Browser Mode 定位器与 Testing Library 查询几乎相同——下文会标出两者命名上的差异。

请按以下顺序使用查询(优先考虑无障碍性):

  1. getByRole - 优先级最高。按 ARIA 角色和无障碍名称查询;贴近屏幕阅读器的体验;促使使用语义化 HTML
  2. getByLabelText - 表单字段,通过关联的 <label> 查询
  3. getByPlaceholder - 当输入框没有标签时的后备方案(占位符不应替代标签)。Testing Library 中的名称是 getByPlaceholderText
  4. getByText - 用户会阅读的非交互内容
  5. getByDisplayValue - 具有预填充值的输入框。仅适用于 Testing Library——Browser Mode 没有此类定位器;请改用 getByRole 并断言其值
  6. getByAltText - 图片
  7. getByTitle - 较少使用,在其他查询不可用时使用
  8. getByTestId - 仅作为最后手段;并非面向用户
查询变体(Testing Library)
  • getBy* - 元素必须存在(找不到时会抛出错误)。用于断言元素存在。
  • queryBy* - 找不到时返回 null。用于断言元素不存在。
  • findBy* - 异步等待元素出现。请参阅 resources/async-patterns.md。

(Browser Mode 定位器是惰性的,并会由 expect.element() 重试,因此 get/query/find 的区别基本不复存在——对于元素不存在的情况,请通过 expect.element 使用 .not.toBeInTheDocument()。)

常见错误
// ❌ WRONG - querySelector (DOM implementation detail)
const button = container.querySelector('.submit-button');
// ❌ WRONG - testId when a role is available (not how users find the button)
screen.getByTestId('submit-button');
// ❌ WRONG - role without accessible name (which button? pages have many)
screen.getByRole('button');
// ✅ CORRECT - role + accessible name (how screen readers find it)
screen.getByRole('button', { name: /submit/i });

// ❌ WRONG - getBy to assert non-existence (awkward throw-based check)
expect(() => screen.getByText(/error/i)).toThrow();
// ✅ CORRECT - queryBy returns null
expect(screen.queryByText(/error/i)).not.toBeInTheDocument();

// Less specific: finds matching text without proving the element's role
screen.getByText(/welcome,\s+john doe/i);
// Preferred when this is a heading: query the accessible role and name
screen.getByRole('heading', { name: /welcome,\s+john doe/i });

无障碍优先测试

使用无障碍查询的三大好处:

  1. 测试贴近真实使用场景 - 像屏幕阅读器一样查询
  2. 提升应用的无障碍性 - 测试会促使使用无障碍标记
  3. 便于重构 - 与用户体验关联,而非与实现方式关联

如果无障碍查询失败,请先检查无障碍名称和角色。失败可能揭示了无障碍问题,但也可能是查询或测试设置有误。

如果查询匹配到多个元素,这同样是在发出信号,并不意味着可以更改查询方式。请通过无障碍方式消除歧义:使用更具体的角色和无障碍名称、结果本身面向用户的可见文本、filter({ hasText: /…/i }),或通过无障碍查询找到的容器(getByRole('region', { name: /…/i })、within(screen.getByRole('form', { name: /…/i })))。如果没有任何无障碍特征能够区分它们,说明页面缺少无障碍名称——请添加名称。切勿通过限定到类或 id 来绕开匹配歧义(page.locator('#panel').getByRole(...)、within(container.querySelector('.panel'))):这会让测试重新依赖用户无法感知的标记,并掩盖导致歧义的无障碍缺陷。

始终优先使用语义化 HTML,而非 ARIA:


<div role="button" onclick="handleClick()" tabindex="0">Submit</div>


<button onclick="handleClick()">Submit</button>

仅在语义化 HTML 不可用时才添加 ARIA(例如,在自定义模态框上使用 role="dialog");切勿在语义化元素上冗余添加 ARIA。


异步测试

在 Browser Mode 中,await expect.element(...) 会自动处理大多数等待情况。在 jsdom 代码库中,元素出现时使用 findBy*,复杂条件使用 waitFor,元素消失时使用 waitForElementToBeRemoved。

**关键规则:**不要在 waitFor 中执行副作用;为使失败信息更清晰,尽量在每个 waitFor 中只写一个断言;切勿将 findBy 包在 waitFor 中。

完整模式和反模式请参阅 resources/async-patterns.md。


使用 MSW 模拟 API

使用 MSW,而不是模拟 fetch/axios——它在网络层进行拦截,因此同一组处理程序可用于测试、Storybook 和开发环境。

此规则适用于应用自己的请求模块,而不仅仅是 fetch 本身。 大多数应用都会用一个模块(api/client/service 文件或生成的 SDK)封装传输层。替换该模块——vi.mock('./api'),或给被测对象传入手写的伪客户端——只是把同一种反模式上移了一层:它会将真正的 URL 构建、序列化、状态处理和错误映射从测试中剔除,因此无法证明应用实际发出了什么请求。模拟响应,绝不模拟负责发起请求的模块。失败路径也遵循这一原则:要让请求失败、超时,或先失败一次再成功,请添加单个测试专用的处理程序(使用 HttpResponse.error() 或非 2xx 状态调用 server.use() / worker.use())——绝不要对自有模块使用 mockRejectedValueOnce。

API 的选择取决于运行环境:

  • 浏览器模式:从 msw/browser 导入 setupWorker(在 setup 文件中启动 worker;通过 worker.use() 为单个测试添加覆盖)
  • Node/jsdom:从 msw/node 导入 setupServer(通过 server.use() 为单个测试添加覆盖)

在浏览器模式下使用 setupServer 不会报错,但会静默失效——测试实际运行在真实浏览器中。两种环境的完整配置请参阅 resources/msw.md。


核心反模式

  1. 测试实现细节——内部状态、私有方法、用作钩子的 CSS 类。应测试用户可见的行为。
  2. 存在无障碍查询时仍使用 querySelector/testId——请参阅上文的查询选择优先级。
  3. 在 beforeEach 中渲染并共享变量——应为每项测试使用工厂函数:
// ❌ WRONG - Shared state across tests
let button;
beforeEach(() => {
  render('<button>Submit</button>');
  button = screen.getByRole('button');
});

// ✅ CORRECT - Factory function per test
const renderButton = () => {
  render('<button>Submit</button>');
  return { button: screen.getByRole('button') };
};

工厂模式请参阅 testing skill。

  1. 在网络层之上伪造网络,而不是使用 MSW——替换 fetch/axios,或使用 vi.mock 模拟应用自己的请求/api/client 模块(包括为了测试错误路径而让其拒绝)——请参阅 resources/msw.md。
  2. 误用 waitFor——请参阅 resources/async-patterns.md。
  3. jsdom 特有的反模式(跳过 screen、使用 fireEvent、测试工具已提供清理功能时仍重复清理、使用属性断言而非 jest-dom 匹配器、缺少 ESLint 插件)——请参阅 resources/dom-testing-library-legacy.md。
  4. 披着浏览器名称外衣的 HTTP 层捷径——使用 page.request/page.evaluate(fetch) 执行某项工作,却声称是“journey”/“browser”/“E2E”测试中用户或前端所做的操作;或伪造浏览器标头(Sec-Fetch-*、Origin)以掩饰非浏览器客户端——请参阅 resources/playwright-e2e.md。

摘要清单

合并 UI 测试前,请确认:

  • 测试框架符合声明、仓库支持情况和成本要求:对于可通过浏览器观察的行为,使用 Browser Mode;若更轻量且稳定的测试框架能够验证契约,则使用该框架
  • 所有 Playwright/Browser Mode 测试都是幂等的(测试之间不共享状态)
  • 查询时优先使用 getByRole(内置或 Testing Library)
  • 使用 expect.element() 进行自动重试断言(Browser Mode)
  • 使用 userEvent 进行交互(在 Browser Mode 中基于 CDP,或使用 @testing-library/user-event)
  • 测试用户能看到的行为,而非实现细节
  • 交接内容(回复、PR 正文、CI 步骤标签)需注明证据来自哪个测试框架,并说明证据无法证明的最接近声明
  • 已验证此测试框架会自动清理,或已在测试设置中注册一次清理操作
  • 组件交互不手动调用 act()(Browser Mode 会处理时序)
  • 使用 MSW 模拟 API——Browser Mode 中使用 setupWorker,Node/jsdom 中使用 setupServer;无论成功还是失败路径,都不使用 vi.mock 或手写应用自身请求模块的替身
  • 遵循 TDD 工作流(参见 tdd 技能)
  • RED/调试浏览器运行时,使用能够证明结果的最小文件/项目/grep 选择范围
  • GREEN/REFACTOR 阶段的浏览器反馈使用运行器、工作区编排器或仓库映射得出的完整受影响范围;如果没有可靠的依赖图,则使用文档说明的“所属测试套件加已知消费者”回退方案,并在不确定时扩大范围,绝不手动挑选测试文件
  • 新建测试会加入活动监视器,或在创建后重新运行受影响的一次性测试;每项声称的结果都实际执行了预期测试
  • Monorepo 运行使用根项目/任务依赖图,并保留所有受影响的工作区消费者
  • 完整的仓库 PR 测试门禁在非监视模式下通过,并针对最终代码树执行,包括完整的必需浏览器/项目矩阵
  • 当重复或嵌套数据采用工厂函数能更清晰时使用工厂函数;简单的一次性值保持内联(参见 testing 技能)
  • 在 Playwright E2E 测试套件中:每项浏览器/用户旅程声明都有一个浏览器发起方;直接 HTTP 仅用于 resources/playwright-e2e.md 分类的狭义角色(契约、设置、就绪检查、后置条件、诊断、外部参与者),绝不将其冒充为浏览器证据
  • 对于 React 特定模式(hooks、context、组件),参见 react-testing 技能

评论

支持 Markdown