关于 react-testing
指导通过可访问、用户可感知的行为测试 React 组件,而非依赖实现细节。适用于 Vitest Browser Mode 和现有 jsdom 环境,涵盖 Hook、表单、上下文及异步 UI。
主要功能
- 01按可访问名称查询元素
- 02测试渲染行为而非内部实现
- 03使用 Vitest Browser Mode 测试 React
- 04覆盖 Hook、表单和上下文
- 05使用 MSW 模拟网络行为
适用场景
- 01通过输入与提交测试表单
- 02验证 Hook 或 Provider 的渲染状态
- 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技能)