关于 react-component-testing
react-component-testing 是面向 Vite、React 和 TypeScript 项目的组件测试技能,组合 RTL、MSW 与 vitest-axe。它侧重模拟网络边界并验证用户可见行为和运行时可访问性。
主要功能
- 01配置 jsdom 组件测试环境
- 02用 RTL 查询并渲染组件
- 03用 user-event 模拟交互
- 04用 MSW 拦截网络请求
- 05用 vitest-axe 检查可访问性
- 06测试 TanStack Query 状态与表单
适用场景
- 01为 React 组件编写交互测试
- 02设置 RTL、MSW 与无障碍测试
- 03模拟数据请求并验证表单提交
- 04覆盖查询的加载、成功和错误状态
安装
复制下面的命令,在终端中运行。
npx skills add https:/ / github.com/ bm629/ agent-skills --skill react-component-testing安装后在 Claude Code 中输入/react-component-testing确认已加载。
包含的文件
除 SKILL.md 外,安装时一起下载的参考资料和脚本。
references/accessibility-with-vitest-axe.mdbrowser-mode.mdmsw-and-generated-clients.mdsources.mdstates-and-forms.md详细介绍
react-component-testing — SKILL.md
**变体:**标准 · **使用时机:**调用此技能后,它会运行至完成,并返回一套已配置并编写好的组件测试层(环境 + setupFiles + 一份完整示例测试),随后控制权交还给调用方。
在 forge 验证的版本。
@testing-library/react16.x ·@testing-library/user-event14.x ·@testing-library/jest-dom6.x ·msw2.x ·vitest-axe0.1.x(封装axe-core)·@tanstack/react-query5.x ·vitest3.x · jsdom。讲解时不依赖特定版本;实际使用时,请根据维护者文档再次确认准确的主版本以及 happy-dom 的注意事项(参见references/sources.md)。
概述
组件测试是测试金字塔的中间层:位于 Vitest 单元测试(纯逻辑,由 vitest 技能负责运行器)之上,Playwright 端到端(e2e)测试(由 playwright-best-practices 技能负责)之下。组件测试会渲染真实的组件树,并断言用户看到和执行的操作,从而捕捉组件、hooks 与数据获取之间的集成问题;这些问题是单元测试会遗漏的,而这种测试方式也比通过浏览器 e2e 驱动每种状态便宜得多。三种工具共同构成一个紧密协作的测试层:**React Testing Library(RTL)**以用户感知组件树的方式渲染并查询组件树,再通过 user-event 驱动交互;Mock Service Worker(MSW v2)模拟网络边界,使应用真实的 HTTP 客户端和序列化流程端到端运行;vitest-axe断言渲染结果不存在可检测到的无障碍违规。核心原则是:测试越接近软件的实际使用方式,就越能让你确信软件的行为正确——因此应按角色或标签查询,像用户一样交互,在网络层而非模块层进行模拟,并断言可观察到的行为,绝不依赖实现细节。此技能负责 RTL + MSW + vitest-axe 的测试编写层和组件测试环境(setupFiles);它会引用运行器、路由测试框架和静态无障碍检查的相关技能,但不会重复讲解。
何时启用
- ✅ 为 Vite + React + TS SPA 编写组件测试:渲染 → 交互 → 断言状态 → 断言无障碍。
- ✅ 配置组件测试环境:
jsdom环境、@vitejs/plugin-react、setupFiles(jest-dom 匹配器、RTL 清理、MSW 服务生命周期、vitest-axe 扩展)。 - ✅ 在测试中模拟 HTTP/数据获取边界——尤其是使用生成的
@hey-api/openapi-ts(或openapi-fetch)客户端时——以便测试真实客户端并发现契约偏差。 - ✅ 通过改变模拟的网络响应,测试 TanStack Query 的加载、空数据、成功和错误状态。
- ✅ 测试表单:通过
user-event填写表单,断言 MSW 处理程序捕获的请求正文,并断言toHaveNoViolations。 - ✅ 为现有组件测试添加运行时无障碍断言。
请勿在以下情况启用:
- 任务涉及 Vitest 运行器本身——配置、覆盖率阈值、监视/CI。该任务由
vitest技能负责;此技能仅作引用。 - 任务涉及浏览器 e2e 测试——Playwright +
@axe-core/playwright由playwright-best-practices负责。(Vitest 浏览器模式作为文档中介绍的替代方案,见references/browser-mode.md,而非默认方案。) - 任务涉及静态无障碍检查——
biome技能负责 lint 阶段的无障碍检查(移植的 jsx-a11y 规则);vitest-axe 是其运行时补充。 - 任务涉及路由测试框架——渲染路由组件需要
createMemoryHistory+ 测试用RouterProvider,由tanstack-router技能负责;此技能仅作引用。 - 任务涉及 React、TanStack Query 或 shadcn 教程——这些各有对应技能;此技能会与它们配合使用。
工作流
步骤 1:牢记网络边界原则(关键决策)
在网络边界使用 MSW 模拟,而不是在模块边界模拟。 当组件跨越网络时,使用 MSW 拦截 HTTP 请求,让应用真正生成的客户端、查询钩子以及请求/响应序列化逻辑全部运行——这样,契约变更(例如字段重命名或新增必填请求体键)就会在测试中暴露出来。对客户端进行模块模拟(vi.mock('./generated-client'))会返回一个预设对象,并且跳过了正在测试的确切层级,因此永远无法发现契约偏移。
规则: 所有跨越网络的内容都用 MSW 模拟;vi.mock 仅用于非 HTTP 协作者——例如时钟(vi.useFakeTimers)、随机 ID/uuid 生成器或非确定性的浏览器 API。详情及生成客户端拦截的具体说明见 references/msw-and-generated-clients.md。
步骤 2:设置组件测试环境(jsdom,绝不用 happy-dom)
在 vitest.config.ts 中:添加 @vitejs/plugin-react,设置 test.environment: 'jsdom'、test.globals: true(这样 RTL 自动清理和 jest-dom 匹配器可以顺利注册;否则需显式导入 { describe, it, expect, afterEach },并在 afterEach 中自行调用 cleanup()),然后将 test.setupFiles 指向一个设置模块。
// vitest.config.ts
import { defineConfig } from 'vitest/config'
import react from '@vitejs/plugin-react'
export default defineConfig({
plugins: [react()],
test: {
environment: 'jsdom',
globals: true,
setupFiles: ['./src/test/setup-tests.ts'],
},
})
// src/test/setup-tests.ts
import '@testing-library/jest-dom/vitest' // toBeInTheDocument, toHaveValue, ...
import 'vitest-axe/extend-expect' // toHaveNoViolations
import { afterAll, afterEach, beforeAll } from 'vitest'
import { server } from './msw-server' // setupServer(...) — see references/msw-and-generated-clients.md
beforeAll(() => server.listen({ onUnhandledRequest: 'error' }))
afterEach(() => server.resetHandlers())
afterAll(() => server.close())
运行 vitest-axe 的测试一律使用 jsdom,不要使用 happy-dom。 Happy DOM 有一个有文档记录的 Node.prototype.isConnected 行为,会破坏 axe-core 的遍历,因此 vitest-axe 断言无法在 happy-dom 下正常工作。jsdom 也很快,且对 CI 的负担较小。(请根据当前文档重新确认这一注意事项——参见 references/sources.md;如果确实必须在非无障碍测试中使用 happy-dom,请将 axe 测试放在 jsdom 项目中。)设置 globals: true 后,RTL 会在测试之间自动卸载并清理 DOM;未设置全局变量时,请添加 afterEach(cleanup)。
步骤 3:以用户感知界面的方式渲染和查询
使用 render(<Component />) 和全局对象 screen。按无障碍查询优先级进行查询——越靠前越好:
getByRole(带无障碍名称,例如screen.getByRole('button', { name: /save/i }))——这是辅助技术和用户感知到的内容;默认首选。getByLabelText(表单字段),然后是getByPlaceholderText。getByText(非交互文案),然后是getByDisplayValue、getByAltText、getByTitle。getByTestId——仅作为最后手段,在没有任何面向用户的内容可用于识别节点时使用。
断言行为,而非实现:关注屏幕上显示的内容以及用户可以执行的操作,绝不要关注组件状态、属性名称或内部调用次数。元素不存在时,getBy* 会抛出异常(用于断言“必须存在”);queryBy* 返回 null(用于断言“必须不存在”);findBy* 是异步的(见下一步)。
步骤 4:使用 user-event v14 驱动交互——始终使用 await
每个测试只创建一次会话,使用 userEvent.setup(),然后对每次交互都使用 await——在 v14 中,每次交互都会返回 Promise,漏掉 await 会导致静默的竞争条件。优先使用 user-event,而不是较底层的 fireEvent;fireEvent 只分发单个原始事件,而 user-event 会模拟真实用户触发的完整事件序列(focus、keydown、input、keyup,等等)。
const user = userEvent.setup()
await user.type(screen.getByLabelText(/email/i), '[email protected]')
await user.click(screen.getByRole('button', { name: /submit/i }))
步骤 5:使用 findBy* / waitFor 处理异步输出
触发获取操作的交互之后,请等待结果。优先使用 findBy*——它将 getBy* + waitFor 结合起来,并不断重试,直到元素出现或超时:expect(await screen.findByText(/saved/i)).toBeInTheDocument()。仅当没有可查询的元素时,才使用单独的 waitFor;此时回调中只能放断言——不要在 waitFor 中执行副作用(例如 user-event 或状态变更),因为回调会重复运行。使用 waitForElementToBeRemoved 断言微调器/加载节点消失。
步骤 6:使用自定义 render 提供应用上下文(TanStack Query、主题等)
大多数组件都需要 Provider。编写一个 test-utils 封装,重新导出 RTL,并提供自定义 render,为每次渲染挂载一个全新的 QueryClient,并设置 retry: false(这样错误状态会立即且确定地显示,而不是进行退避重试):
// src/test/test-utils.tsx
import { type ReactElement, type ReactNode } from 'react'
import { render, type RenderOptions } from '@testing-library/react'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
function makeWrapper() {
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } })
return ({ children }: { children: ReactNode }) => (
<QueryClientProvider client={client}>{children}</QueryClientProvider>
)
}
export function renderWithProviders(ui: ReactElement, options?: RenderOptions) {
return render(ui, { wrapper: makeWrapper(), ...options })
}
export * from '@testing-library/react'
每次渲染使用新的客户端(并在 afterEach 中调用 server.resetHandlers())可避免测试间缓存相互污染。对于带路由的组件,将 tanstack-router 技能中的测试 RouterProvider 和 createMemoryHistory 组合到此封装中——该测试框架由该技能维护;不要在此重新推导。每项测试中都应调整 MSW 响应,以覆盖加载、空({ data: [] })、成功和错误(500 / ProblemDetail)状态——请参阅 references/states-and-forms.md。
步骤 7:使用 vitest-axe 断言无障碍性
渲染后(并到达要检查的状态后),对渲染容器运行 axe,并断言没有违规项。可在设置文件中通过 import 'vitest-axe/extend-expect' 注册匹配器(或通过 vitest-axe/matchers 中的 expect.extend(matchers) 注册):
import { axe } from 'vitest-axe'
const { container } = renderWithProviders(<SignupForm />)
expect(await axe(container)).toHaveNoViolations()
vitest-axe 需要真实 DOM,因此要使用 jsdom(步骤 2)。请如实界定范围: 自动化 axe-core 只能检测 WCAG 问题中的一部分(常见说法是大约三分之一到一半),它可以补充人工和辅助技术审查,但绝不能取代这些审查。详情和规则配置见 references/accessibility-with-vitest-axe.md。
步骤 8:完整示例——表单 → user-event → MSW 断言请求正文 → 状态 → 无无障碍违规
// src/features/signup/SignupForm.test.tsx (colocated *.test.tsx)
import { http, HttpResponse } from 'msw'
import { axe } from 'vitest-axe'
import { renderWithProviders, screen } from '../../test/test-utils'
import { server } from '../../test/msw-server'
import userEvent from '@testing-library/user-event'
import { SignupForm } from './SignupForm'
it('submits the form and shows success', async () => {
const user = userEvent.setup()
let captured: unknown
server.use(
http.post('/api/users', async ({ request }) => {
captured = await request.json() // assert the REAL request body
return HttpResponse.json({ id: 'u1' }, { status: 201 })
}),
)
const { container } = renderWithProviders(<SignupForm />)
await user.type(screen.getByLabelText(/email/i), '[email protected]')
await user.type(screen.getByLabelText(/^password/i), 's3cret!')
await user.click(screen.getByRole('button', { name: /create account/i }))
expect(await screen.findByText(/account created/i)).toBeInTheDocument()
expect(captured).toEqual({ email: '[email protected]', password: 's3cret!' })
// security invariant: a field that must never leave the form is absent both places
expect(screen.queryByLabelText(/internal token/i)).not.toBeInTheDocument()
expect(captured).not.toHaveProperty('internalToken')
expect(await axe(container)).toHaveNoViolations()
})
此示例覆盖整个层:真实客户端会序列化请求正文,MSW 在网络边界捕获请求,页面渲染成功状态,并对输出进行无障碍检查。错误/验证变体和安全不变量模式见 references/states-and-forms.md。
步骤 9:约定 + CI
将组件测试文件 *.test.tsx 放在组件旁边。将 test-utils.tsx、setup-tests.ts 和 msw-server.ts 放在 src/test/ 文件夹中。覆盖率、阈值、监听/CI 和运行器配置由 vitest 技能负责——通过该技能的 test 脚本和 CI 门禁运行测试套件;本技能不会重复讲解运行器。
规则
硬性规则(绝不违反):
- 使用 MSW 在网络边界进行模拟;绝不要对生成的 HTTP 客户端进行模块模拟。 模块模拟会跳过客户端及其序列化过程,也无法发现契约偏差。
vi.mock仅用于非 HTTP 协作者。 - 每次
user-event交互都必须await。 在 v14 中,每次交互都会返回一个 Promise;漏掉await会悄然引发竞态,并导致测试不稳定或产生假阳性。 - 任何 vitest-axe 测试都必须使用
jsdom,不能使用happy-dom。 Happy DOM 的isConnected行为会破坏 axe 遍历;vitest-axe 无法在其上运行。 - 必须先运行
server.listen(),再创建 HTTP 客户端。 MSW 会在listen()时安装拦截器;在此之前捕获的客户端/fetch 会绕过模拟(请参阅参考资料)。 - 优先使用无障碍查询(
getByRole/getByLabelText);getByTestId仅作为最后手段。测试可观察到的行为,绝不测试实现细节。 waitFor回调中只能放断言——不能有副作用;有合适的查询时,优先使用findBy*,而不是手动调用waitFor。- 每次渲染都使用全新的
QueryClient并设置retry: false,且每项测试都调用resetHandlers()——不得共享客户端,也不得让缓存或处理器在测试间相互污染。
偏好(可覆盖):
- 有可查询的元素时,优先使用
findBy*,而不是waitFor。 - 每项测试优先使用一次
userEvent.setup(),而不是已弃用的直接调用userEvent.click(...)。 - 在
server.listen()中保留onUnhandledRequest: 'error',以便未模拟的请求能明确失败,而不是访问真实网络。 - 应针对实际发布的状态(加载后、错误后)进行无障碍断言,而不只检查初始渲染。
常见问题
- 在
user-event调用中漏掉await。 断言运行前,交互的 Promise 尚未完成;测试可能因此错误地通过,或偶发失败。务必使用await。 - happy-dom + axe = 静默故障。 测试虽然“运行”了,但由于
Node.prototype.isConnected的 bug,vitest-axe 的行为不正确。请将无障碍测试固定为使用jsdom。 - 在调用
listen()之前创建客户端。 在模块加载时实例化fetch的生成客户端(顶层的const client = createClient())可能会绑定到 MSW 尚未修补的fetch引用。确保 MSW 的设置文件位于setupFiles中(最先运行);对于某些生成的/openapi-fetch客户端,还要显式传入 MSW 可见的fetch——请参阅references/msw-and-generated-clients.md。 - 在处理程序路径中加入查询参数。 MSW 按路径名匹配;在
http.get('/api/x?foo=bar', ...)中加入?foo=bar不会匹配——应在处理程序中通过new URL(request.url)读取查询参数。 - 在
waitFor中执行副作用。waitFor会多次重新运行回调;在其中调用user-event或修改状态会导致重复触发。回调中只保留断言。 - 忘记 jest-dom 的
/vitest入口。 在 Vitest 下,导入@testing-library/jest-dom(Jest 入口)而不是@testing-library/jest-dom/vitest,会导致匹配器未注册。 - 在测试之间复用过期的 QueryClient。 复用同一个客户端会缓存第一次响应;后续测试会读取过期数据。每次渲染都创建新客户端。
- 过度依赖 axe。 零违规是必要条件,但并非充分条件——它只覆盖 WCAG 的部分要求;仍需人工或辅助技术审查。
反模式
- “直接用
vi.mock模拟 API 客户端,更简单。” 这样会删掉正在测试的那一层;生成的客户端和序列化逻辑都不会运行,因此无法发现契约漂移。请使用 MSW 模拟网络。 - “到处用
getByTestId,这样更稳定。” 测试 ID 会绕过无障碍树,让无法访问的 UI 也能通过测试。应按角色或标签查询;testid 是最后的手段。 - “
fireEvent.click没问题。” 它只派发一个合成事件,而不是真实的交互序列;对于用户实际会执行的操作,应优先使用user-event。 - “happy-dom 更快,无障碍测试也用它吧。” 它会导致 axe 出错。速度上的收益不值得换来无声失效的无障碍断言。
- “断言组件调用了
fetch/ 钩子的内部状态。” 这会让测试与实现耦合——重构时,即便行为未变,测试也会失败。应断言用户看到的内容以及服务端收到的请求。 - “在这里重新讲解 Vitest 配置 / 覆盖率。” 这不在范围内——运行器由
vitest技能负责;请参考该技能。
输出
为 Vite + React + TS SPA 搭建可正常工作的组件测试层:使用 @vitejs/plugin-react 的 jsdom Vitest 环境;一个 setupFiles 模块,负责配置 jest-dom 匹配器、RTL 清理、MSW 服务生命周期以及 vitest-axe 扩展;一个 test-utils 自定义 render(使用全新的 TanStack Query 客户端、retry:false,并可与路由测试框架组合);以及编写的 *.test.tsx 文件,用于渲染、通过 user-event 驱动交互、使用 MSW 模拟网络(包括生成的客户端)、断言请求正文和 UI 状态,并断言 toHaveNoViolations。使用者是下一阶段工作流中的开发者或被派发的 Agent,他们现在将编写由契约驱动且无障碍的组件测试;运行器、覆盖率和 CI 由 vitest 技能提供。
相关内容
vitest— 负责测试运行器、配置、覆盖率阈值、监视和 CI;本技能负责在此基础上编写组件测试环境以及 RTL/MSW/axe 测试。tanstack-query— 提供受测的数据获取钩子;本技能介绍每个测试使用的QueryClientProvider(retry:false),而非该库本身。tanstack-router— 负责路由器测试框架(createMemoryHistory+ 测试用RouterProvider);对于带路由的组件,将其组合到自定义render中。playwright-best-practices— 对应的端到端测试技能(真实浏览器、@axe-core/playwright);组件测试位于其下层。另请参阅references/browser-mode.md。biome— 静态 jsx-a11y lint(lint 阶段);vitest-axe 是其运行时补充。shadcn— 受测组件和表单。
渐进式披露
references/msw-and-generated-clients.md— MSW v2 的setupServer/http/HttpResponse、Node 生命周期、每个测试的覆盖项、读取请求正文、查询参数警告,以及拦截生成的@hey-api/openapi-ts/openapi-fetch客户端(先监听再创建的顺序 + 自定义fetch注意事项)。在模拟网络时加载。references/accessibility-with-vitest-axe.md— vitest-axe 设置变体、toHaveNoViolations、jsdom 要求、规则配置,以及如实说明 WCAG 子集的覆盖范围。在添加无障碍断言时加载。references/states-and-forms.md— 使用 MSW + TanStack Query 测试加载/空/成功/错误状态,以及表单 + 请求正文 + 安全不变量断言模式。在测试数据状态或表单时加载。references/browser-mode.md— 文档记录的替代方案:Vitest 浏览器模式(Playwright 提供程序)+@axe-core/playwright,以及相应权衡(速度较慢、CI 开销更大、无障碍测试从 vitest-axe 转移、与端到端测试技能重叠)。默认使用 jsdom;仅在需要真实浏览器保真度时加载。references/sources.md— 研究来源 + 根据文档核验的清单。
正文预算
description≤ 1,024 个字符。- 正文 ≤ 约 500 行 / 5,000 个 token;大量内容放在
references/中,按需加载。