关于 front-end-testing
用于规划和评估前端测试的技能,涵盖用户可见行为、浏览器测试及测试证据的边界说明,适合处理不同测试环境下 UI 测试的 Agent。
主要功能
- 01优先验证用户可见行为
- 02比较 jsdom 与浏览器测试
- 03使用 Vitest Browser Mode
- 04指导 Playwright E2E 测试
- 05说明测试证据的边界
适用场景
- 01为依赖浏览器的行为选择测试方式
- 02围绕用户交互编写 UI 测试
- 03说明测试通过不能证明的内容
安装
复制下面的命令,在终端中运行。
npx skills add https:/ / github.com/ citypaul/ .dotfiles --skill front-end-testing安装后在 Claude Code 中输入/front-end-testing确认已加载。
包含的文件
除 SKILL.md 外,安装时一起下载的参考资料和脚本。
resources/async-patterns.mddom-testing-library-legacy.mdmsw.mdplaywright-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 有两类用户:
- 最终用户:通过 DOM 交互(点击、输入、阅读文本)
- 开发者:重构实现的你
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-dom | Browser 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 查询几乎相同——下文会标出两者命名上的差异。
请按以下顺序使用查询(优先考虑无障碍性):
getByRole- 优先级最高。按 ARIA 角色和无障碍名称查询;贴近屏幕阅读器的体验;促使使用语义化 HTMLgetByLabelText- 表单字段,通过关联的<label>查询getByPlaceholder- 当输入框没有标签时的后备方案(占位符不应替代标签)。Testing Library 中的名称是getByPlaceholderTextgetByText- 用户会阅读的非交互内容getByDisplayValue- 具有预填充值的输入框。仅适用于 Testing Library——Browser Mode 没有此类定位器;请改用getByRole并断言其值getByAltText- 图片getByTitle- 较少使用,在其他查询不可用时使用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 });
无障碍优先测试
使用无障碍查询的三大好处:
- 测试贴近真实使用场景 - 像屏幕阅读器一样查询
- 提升应用的无障碍性 - 测试会促使使用无障碍标记
- 便于重构 - 与用户体验关联,而非与实现方式关联
如果无障碍查询失败,请先检查无障碍名称和角色。失败可能揭示了无障碍问题,但也可能是查询或测试设置有误。
如果查询匹配到多个元素,这同样是在发出信号,并不意味着可以更改查询方式。请通过无障碍方式消除歧义:使用更具体的角色和无障碍名称、结果本身面向用户的可见文本、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。
核心反模式
- 测试实现细节——内部状态、私有方法、用作钩子的 CSS 类。应测试用户可见的行为。
- 存在无障碍查询时仍使用
querySelector/testId——请参阅上文的查询选择优先级。 - 在 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。
- 在网络层之上伪造网络,而不是使用 MSW——替换
fetch/axios,或使用vi.mock模拟应用自己的请求/api/client 模块(包括为了测试错误路径而让其拒绝)——请参阅resources/msw.md。 - 误用 waitFor——请参阅
resources/async-patterns.md。 - jsdom 特有的反模式(跳过
screen、使用fireEvent、测试工具已提供清理功能时仍重复清理、使用属性断言而非 jest-dom 匹配器、缺少 ESLint 插件)——请参阅resources/dom-testing-library-legacy.md。 - 披着浏览器名称外衣的 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技能