mcpField
R
react-testing

react-testing

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

关于 react-testing

指导通过可访问、用户可感知的行为测试 React 组件,而非依赖实现细节。适用于 Vitest Browser Mode 和现有 jsdom 环境,涵盖 Hook、表单、上下文及异步 UI。

主要功能

  1. 01按可访问名称查询元素
  2. 02测试渲染行为而非内部实现
  3. 03使用 Vitest Browser Mode 测试 React
  4. 04覆盖 Hook、表单和上下文
  5. 05使用 MSW 模拟网络行为

适用场景

  1. 01通过输入与提交测试表单
  2. 02验证 Hook 或 Provider 的渲染状态
  3. 03选择浏览器或 jsdom 组件测试环境

安装

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

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

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

附带文件resources/testing-library-react-legacy.md

详细介绍

React 测试

对于通用 UI 测试模式(查询、事件、异步、无障碍、MSW),请加载 front-end-testing 技能。对于 TDD 工作流,请加载 tdd 技能。

始终按照人们感知元素的方式查找元素——绝不要使用 data-testid、CSS 类或选择器,尤其不能因为组件已经带有这些标记就使用它们。 使用带有可访问名称的 getByRole 查找控件(字段使用 getByLabelText)。对于没有角色的元素——计数行、状态消息、说明文字段落、只有文本的 <span>——根据其显示的文字查找:getByText(/3 of 7 items/),然后对同一个定位器进行断言(toBeVisible()、toHaveAttribute(...))。绝不要先通过测试 ID 获取句柄,再断言其内容。如果元素没有任何足够可感知的信息可供查询,这说明组件存在问题:为它赋予其用途所暗示的角色、标签或可访问名称,并据此查询。

对于驱动组件的流程逻辑,请加载 xstate:机器应以无头方式测试,组件测试只操作 DOM,因此组件测试绝不能断言机器状态。如果被测组件在 useState 中持有 submitting/isLoading 标志,这就表明流程逻辑已经脱离机器——该调用应由 xstate 管理。对于同一代码的性能变更,请加载 react-performance,其规则是行为测试保持不变且通过。

遵循 tdd 技能的规范快速反馈与 watcher 生命周期策略,以及 front-end-testing 技能中针对浏览器的差异说明。React 不会额外保证 Vitest 图:优先使用仓库自有的 watcher;只有在符合规范的版本/配置验证条件下才使用按 diff 选择的 watch,并确保所有受影响的应用/包使用方都能通过根图纳入测试。精确文件仅用于 RED/调试。在 PR 就绪时,停止 watcher,并执行目标仓库的变更策略以及完整的非 watch UI/项目门禁。

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

资源加载时机
resources/testing-library-react-legacy.md在使用 @testing-library/react + jsdom 的代码库中工作时——同步渲染、screen 查询、导入的 act、渲染辅助函数、旧式表单/hook/context 示例

使用 React 的 Vitest Browser Mode

当测试结论依赖真实渲染、事件、焦点、CSS、无障碍或浏览器 API,且仓库支持相应测试框架或新增成本合理时,优先使用 vitest-browser-react。如果现有稳定的 @testing-library/react/jsdom 测试框架已经足以验证纯 hook、provider 或组件逻辑,则继续使用该框架,或使用更轻量的环境。

设置

根据 front-end-testing 技能扩展 Browser Mode 配置,加入 React 插件和 vitest-browser-react。遵循该技能关于仓库包管理器、精确版本、授权和本地二进制文件设置的策略:

<repo-pm> add --save-dev vitest@<reviewed-version> @vitest/browser-playwright@<reviewed-version> vitest-browser-react@<reviewed-version> @vitejs/plugin-react@<reviewed-version>
// vitest.config.ts — same as front-end-testing Browser Mode config, plus:
import react from '@vitejs/plugin-react'

export default defineConfig({
  plugins: [react()],
  test: {
    browser: { /* unchanged from front-end-testing setup */ },
  },
})
组件测试
import { render } from 'vitest-browser-react'
import { expect, test } from 'vitest'

test('should display user name when provided', async () => {
  const screen = await render(<UserProfile name="Alice" email="[email protected]" />)

  await expect.element(screen.getByText(/alice/i)).toBeVisible()
  await expect.element(screen.getByText(/[email protected]/i)).toBeVisible()
})

与 @testing-library/react 的主要区别:

  • render() 和 renderHook() 是异步的——使用 await
  • 返回作用域限定为已渲染组件的 screen
  • 使用 expect.element() 进行自动重试断言
  • 通过定位器进行组件交互时无需使用 act() 包装器——CDP 事件和重试会处理时序问题。renderHook 的状态更新仍需要 act(由 renderHook 返回,见下文)
  • 自动清理会在每个测试之前(而非之后)执行,因此组件会保留在页面上,便于调试
测试属性和回调
test('should call onSubmit when form submitted', async () => {
  const handleSubmit = vi.fn()
  const screen = await render(<LoginForm onSubmit={handleSubmit} />)

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

  expect(handleSubmit).toHaveBeenCalledWith({
    email: '[email protected]',
  })
})
测试条件渲染(使用 MSW)

Browser Mode 测试在真实浏览器中运行,因此应使用 MSW 的 setupWorker(msw/browser),而不是 setupServer。在设置文件中启动 worker,并通过每个测试中的 worker.use() 覆盖行为。完整设置请参阅:front-end-testing 技能、resources/msw.md。

import { http, HttpResponse } from 'msw'
import { worker } from '../vitest.browser.setup'

test('should show error message when login fails', async () => {
  worker.use(
    http.post('/api/login', () => {
      return HttpResponse.json({ error: 'Invalid credentials' }, { status: 401 })
    })
  )

  const screen = await render(<LoginForm />)

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

  await expect.element(screen.getByText(/invalid credentials/i)).toBeVisible()
})
使用 renderHook 测试 Hooks

renderHook() 是异步的,并会将 act 与 result 一起返回——使用该 act 更新 hook 状态:

import { renderHook } from 'vitest-browser-react'

test('should toggle value', async () => {
  const { result, act } = await renderHook(() => useToggle(false))

  expect(result.current.value).toBe(false)

  await act(() => {
    result.current.toggle()
  })

  expect(result.current.value).toBe(true)
})
测试上下文提供程序
test('should show user menu when authenticated', async () => {
  const screen = await render(
    <AuthProvider initialUser={{ name: 'Alice', role: 'admin' }}>
      <Dashboard />
    </AuthProvider>
  )

  await expect.element(screen.getByRole('button', { name: /user menu/i })).toBeVisible()
})

对于需要上下文的钩子:

const { result } = await renderHook(() => useAuth(), {
  wrapper: ({ children }) => (
    <AuthProvider>{children}</AuthProvider>
  ),
})
测试表单
test('should submit form with user input', async () => {
  const handleSubmit = vi.fn()
  const screen = await render(<RegistrationForm onSubmit={handleSubmit} />)

  await screen.getByLabelText(/name/i).fill('Alice')
  await screen.getByLabelText(/email/i).fill('[email protected]')
  await screen.getByLabelText(/password/i).fill('password123')
  await screen.getByRole('button', { name: /sign up/i }).click()

  expect(handleSubmit).toHaveBeenCalledWith({
    name: 'Alice',
    email: '[email protected]',
    password: 'password123',
  })
})

test('should show validation errors for invalid input', async () => {
  const screen = await render(<RegistrationForm />)

  // Submit empty form
  await screen.getByRole('button', { name: /sign up/i }).click()

  // Validation errors appear
  await expect.element(screen.getByText(/name is required/i)).toBeVisible()
  await expect.element(screen.getByText(/email is required/i)).toBeVisible()
  await expect.element(screen.getByText(/password is required/i)).toBeVisible()
})
测试加载状态
test('should show loading then data', async () => {
  const screen = await render(<UserList />)

  await expect.element(screen.getByText(/loading/i)).toBeVisible()

  await expect.element(screen.getByText(/alice/i)).toBeVisible()
  await expect.element(screen.getByText(/loading/i)).not.toBeInTheDocument()
})
测试错误边界
test('should catch errors with error boundary', async () => {
  // Suppress console.error noise for this test
  const spy = vi.spyOn(console, 'error').mockImplementation(() => {})

  try {
    const screen = await render(
      <ErrorBoundary fallback={<div>Something went wrong</div>}>
        <ThrowsError />
      </ErrorBoundary>
    )

    await expect.element(screen.getByText(/something went wrong/i)).toBeVisible()
  } finally {
    spy.mockRestore()
  }
})
测试 Portal
import { page } from 'vitest/browser'

test('should render modal in portal', async () => {
  const screen = await render(<Modal isOpen={true}>Modal content</Modal>)

  // Portal renders outside the component root; query the page instead
  await expect.element(page.getByText(/modal content/i)).toBeVisible()
})

返回的 screen 的作用范围仅限于渲染的组件;对于 Portal 内容,请使用 vitest/browser 中面向整个文档的 page。

测试 Suspense
test('should show fallback then content', async () => {
  const screen = await render(
    <Suspense fallback={<div>Loading...</div>}>
      <LazyComponent />
    </Suspense>
  )

  await expect.element(screen.getByText(/loading/i)).toBeVisible()

  await expect.element(screen.getByText(/lazy content/i)).toBeVisible()
})
React 服务端组件

无法在 Browser Mode 组件测试中测试 RSC,因为它们在服务端执行,而不是在浏览器中执行。请使用端到端测试(针对运行中的应用使用 Playwright)或对从组件中提取的逻辑进行单元测试。客户端组件('use client')可使用 vitest-browser-react 正常测试。


React 专属反模式

1. 不必要地使用 act() 包装

❌ 错误 - 在渲染和交互周围手动使用 act()

await act(async () => {
  await screen.getByRole('button').click()
})

✅ 正确 - 定位器事件会处理时序

await screen.getByRole('button').click()

确实需要 act() 的情况: 通过 renderHook 更新钩子状态(使用它返回的 act)。在 @testing-library/react 中,RTL 会自动包装 render/userEvent/waitFor——参见 resources/testing-library-react-legacy.md。

2. 测试组件内部实现

❌ 错误 - 访问组件内部实现

const wrapper = shallow(<MyComponent />);
expect(wrapper.state('isOpen')).toBe(true); // Internal state
expect(wrapper.instance().handleClick).toBeDefined(); // Internal method

✅ 正确 - 测试渲染结果

const screen = await render(<MyComponent />)
await expect.element(screen.getByRole('dialog')).toBeVisible() // What user sees
3. 浅渲染——以及它的现代形式:模拟子组件

绝不要对被测应用拥有的组件使用 vi.mock。 用替身替换子组件,不过是换个名字的浅渲染,而且如今这种做法更为常见。当请求你用替身代替某个子组件,因为其标记不归你负责时,应满足其关注点,而非照搬其做法:渲染整棵组件树,只断言父组件自身可观察到的事实——屏幕上显示了哪些项目、计数行、空状态——不要固定子组件的任何标记。应在真正的边界处进行模拟(通过 MSW 模拟网络、时钟、随机性),绝不要在自己的组件树内部模拟。

❌ 错误 - 用替身替换子组件,或进行浅渲染

vi.mock('./ItemRow', () => ({ ItemRow: ({ item }) => <li>{item.title}</li> }))
const wrapper = shallow(<MyComponent />);
// Child components not rendered - incomplete test

✅ 正确 - 完整渲染

await render(<MyComponent />)
// Full component tree rendered - realistic test

原因: 浅渲染会掩盖父子组件之间的集成错误。

4. 共享渲染与清理归属

共享的可变渲染状态才是问题所在,而不是生命周期钩子。独立的 beforeEach 可以为每个非并发测试创建全新状态;只有在重复或嵌套的设置 变得更清晰时,才使用辅助函数。只有当测试框架提供预期的全局 afterEach 时, Testing Library 才会自动清理;否则,请在测试设置中显式注册 afterEach(() => cleanup())。


总结清单

React 专项检查:

  • 当所需行为可在浏览器中观察,且仓库支持并且成本合适时,使用 vitest-browser-react
  • 当稳定、轻量的测试框架足以验证组件或 Hook 契约时,保留 @testing-library/react(参见 resources/testing-library-react-legacy.md)
  • 所有 Playwright/Browser Mode 测试均具备幂等性(测试之间没有共享状态)
  • 等待 render()/renderHook() 完成(它们在 vitest-browser-react 中是异步的)
  • 对自定义 Hook 使用 renderHook(),并使用其返回的 act 更新状态
  • 使用 wrapper 选项包装上下文提供程序
  • 不在渲染或定位器交互外手动调用 act()
  • 已验证此测试框架会自动清理,或在测试设置中仅注册一次清理逻辑
  • 在 Browser Mode 中通过 setupWorker/worker.use() 使用 MSW(而非 setupServer)
  • 测试组件输出,而非内部状态
  • 每个元素都通过角色、标签或可见文本查找——不使用 getByTestId、data-testid、选择器或类名,即使组件提供了这些标识也不例外
  • 不使用 vi.mock 模拟应用拥有的组件;渲染整个组件树,且父组件的测试只断言父组件自身的事实
  • 每项设置均按测试隔离;生命周期钩子可以创建全新状态,仅当重复或嵌套设置因此更清晰时才使用辅助函数
  • 使用 expect.element() 进行自动重试断言(Browser Mode)
  • 通过端到端测试或提取出的逻辑测试 RSC,而非通过 Browser Mode 组件测试
  • 遵循 TDD 工作流(参见 tdd 技能)
  • GREEN/REFACTOR 使用由运行器、工作区编排器或仓库映射确定的完整受影响反馈范围——若不存在可靠依赖图,则使用文档说明的扩大范围备用方案——而非手动挑选测试文件;完整的仓库 PR 测试门禁应为最新,并包含配置的完整 UI 测试套件
  • 遵循通用 UI 测试模式(参见 front-end-testing 技能)
  • 当重复或嵌套数据用工厂更清晰时使用工厂;简单的一次性值则保持内联(参见 testing 技能)

评论

支持 Markdown