关于 react-testing
面向 React 开发者的测试参考,涵盖使用 Vitest 和 Testing Library 测试组件行为、用户交互、异步更新与模拟。
主要功能
- 01Vitest 和 Testing Library 配置
- 02按无障碍语义查询元素
- 03测试用户交互
- 04测试异步状态与更新
- 05模拟函数和模块
适用场景
- 01通过渲染界面测试组件行为
- 02检查表单交互和提交
- 03覆盖加载过程与异步数据
- 04模拟 API 模块和函数结果
MCP 相关
该技能指向 `mcp__documentation__fetch_docs`,用于获取 React 测试文档。
安装
复制下面的命令,在终端中运行。
$
npx skills add https:/ / github.com/ claude-dev-suite/ claude-dev-suite --skill react-testing安装后在 Claude Code 中输入/react-testing确认已加载。
附带文件advanced.md
详细介绍
React 测试
完整参考:有关 MSW 设置、测试钩子、测试上下文、表单、无障碍测试、快照和测试模式,请参阅 advanced.md。
深入了解:使用
mcp__documentation__fetch_docs,指定 technology:react、topic:testing,获取完整文档。
使用 Vitest + Testing Library 进行设置
// 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.ts',
css: true,
},
});
// src/test/setup.ts
import '@testing-library/jest-dom/vitest';
import { cleanup } from '@testing-library/react';
import { afterEach } from 'vitest';
afterEach(() => {
cleanup();
});
基本组件测试
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { describe, it, expect, vi } from 'vitest';
describe('Button', () => {
it('renders children', () => {
render(<Button onClick={() => {}}>Click me</Button>);
expect(screen.getByRole('button', { name: /click me/i })).toBeInTheDocument();
});
it('calls onClick when clicked', async () => {
const handleClick = vi.fn();
render(<Button onClick={handleClick}>Click me</Button>);
await userEvent.click(screen.getByRole('button'));
expect(handleClick).toHaveBeenCalledTimes(1);
});
it('is disabled when disabled prop is true', () => {
render(<Button onClick={() => {}} disabled>Click me</Button>);
expect(screen.getByRole('button')).toBeDisabled();
});
});
查询方法
// Priority order (prefer accessible queries)
// 1. getByRole - accessible to everyone
screen.getByRole('button', { name: /submit/i });
screen.getByRole('textbox', { name: /email/i });
screen.getByRole('heading', { level: 1 });
// 2. getByLabelText - for form fields
screen.getByLabelText(/email address/i);
// 3. getByPlaceholderText
screen.getByPlaceholderText(/enter your email/i);
// 4. getByText - for non-interactive elements
screen.getByText(/welcome to our app/i);
// 5. getByDisplayValue - for input values
screen.getByDisplayValue(/[email protected]/i);
// 6. getByAltText - for images
screen.getByAltText(/user avatar/i);
// 7. getByTitle
screen.getByTitle(/close/i);
// 8. getByTestId - last resort
screen.getByTestId('custom-element');
// Query variants
screen.getByRole('button'); // Throws if not found
screen.queryByRole('button'); // Returns null if not found
screen.findByRole('button'); // Returns Promise, waits for element
screen.getAllByRole('button'); // Returns array, throws if none
screen.queryAllByRole('button'); // Returns array (possibly empty)
screen.findAllByRole('button'); // Returns Promise of array
用户事件
import userEvent from '@testing-library/user-event';
describe('Form', () => {
it('submits with user input', async () => {
const user = userEvent.setup();
const handleSubmit = vi.fn();
render(<LoginForm onSubmit={handleSubmit} />);
// Type in inputs
await user.type(screen.getByLabelText(/email/i), '[email protected]');
await user.type(screen.getByLabelText(/password/i), 'secret123');
// Click submit
await user.click(screen.getByRole('button', { name: /sign in/i }));
expect(handleSubmit).toHaveBeenCalledWith({
email: '[email protected]',
password: 'secret123',
});
});
it('handles keyboard navigation', async () => {
const user = userEvent.setup();
render(<Form />);
// Tab through inputs
await user.tab();
expect(screen.getByLabelText(/email/i)).toHaveFocus();
await user.tab();
expect(screen.getByLabelText(/password/i)).toHaveFocus();
// Type and submit with Enter
await user.type(screen.getByLabelText(/password/i), 'secret{Enter}');
});
it('handles select and checkbox', async () => {
const user = userEvent.setup();
render(<SettingsForm />);
// Select option
await user.selectOptions(screen.getByRole('combobox'), ['dark']);
// Toggle checkbox
await user.click(screen.getByRole('checkbox', { name: /notifications/i }));
expect(screen.getByRole('checkbox')).toBeChecked();
});
});
异步测试
describe('UserProfile', () => {
it('shows loading then user data', async () => {
render(<UserProfile userId="123" />);
// Initially shows loading
expect(screen.getByText(/loading/i)).toBeInTheDocument();
// Wait for data to load
await waitFor(() => {
expect(screen.getByText('John Doe')).toBeInTheDocument();
});
// Loading is gone
expect(screen.queryByText(/loading/i)).not.toBeInTheDocument();
});
});
// Using findBy (combines getBy + waitFor)
it('loads and displays items', async () => {
render(<ItemList />);
// findBy waits for element
const items = await screen.findAllByRole('listitem');
expect(items).toHaveLength(3);
});
// waitForElementToBeRemoved
it('removes loading indicator', async () => {
render(<DataLoader />);
await waitForElementToBeRemoved(() => screen.queryByText(/loading/i));
expect(screen.getByText('Data loaded')).toBeInTheDocument();
});
模拟
模拟函数
import { vi } from 'vitest';
const mockFn = vi.fn();
mockFn.mockReturnValue('default');
mockFn.mockReturnValueOnce('first call');
mockFn.mockImplementation((x) => x * 2);
mockFn.mockResolvedValue({ data: [] });
mockFn.mockRejectedValue(new Error('Failed'));
// Assertions
expect(mockFn).toHaveBeenCalled();
expect(mockFn).toHaveBeenCalledTimes(2);
expect(mockFn).toHaveBeenCalledWith('arg1', 'arg2');
expect(mockFn).toHaveBeenLastCalledWith('last arg');
模拟模块
// Mock a module
vi.mock('@/lib/api', () => ({
fetchUsers: vi.fn(() => Promise.resolve([{ id: 1, name: 'John' }])),
}));
// Import after mocking
import { fetchUsers } from '@/lib/api';
it('fetches users', async () => {
render(<UserList />);
expect(fetchUsers).toHaveBeenCalled();
await screen.findByText('John');
});
常见问题
| 问题 | 原因 | 解决方案 |
|---|---|---|
| 测试找不到元素 | 元素是异步渲染的 | 使用 findBy 或 waitFor |
| 状态未更新 | 缺少 act() | 使用 userEvent(会处理 act) |
| 测试相互影响 | 共享状态 | 在 afterEach 中清理 |
| 测试不稳定 | 竞态条件 | 使用恰当的异步模式 |
最佳实践
- 测试行为,而非实现
- 使用无障碍查询(getByRole)
- 优先使用 userEvent,而非 fireEvent
- 测试错误状态
- 使用 MSW 模拟 API
- 不要测试实现细节
- 不要测试第三方库
- 不要过度使用快照
不适用此技能的情况
- 端到端测试 - 使用 Playwright 技能测试完整的 E2E 流程
- 后端测试 - 使用特定框架的测试技能
- 性能测试 - 使用专用的性能测试工具
- 视觉回归测试 - 使用 Percy 或 Chromatic 等工具
反模式
| 反模式 | 问题 | 解决方案 |
|---|---|---|
| 测试实现细节 | 测试脆弱 | 测试面向用户的行为 |
| 优先使用 getByTestId | 未测试无障碍性 | 优先使用 getByRole、getByLabelText |
| 使用 fireEvent 而非 userEvent | 无法模拟真实的用户交互 | 使用 userEvent 编写逼真的测试 |
| 未等待异步更新 | 测试不稳定 | 使用 findBy 或 waitFor |
| 对所有内容进行快照测试 | 难以维护 | 谨慎用于稳定的 UI |
| 测试第三方库 | 浪费精力 | 信任库自身的测试,测试你的集成 |
| 未测试错误状态 | 遗漏边缘情况 | 测试加载、错误和空状态 |
| 浅渲染 | 遗漏集成问题 | 使用完整渲染 |
快速故障排除
| 问题 | 可能原因 | 修复方法 |
|---|---|---|
| 找不到元素 | 查询时机不对、查询方式错误 | 异步场景使用 findBy,或检查查询方式 |
| 测试超时 | 异步操作未完成 | 检查 waitFor 超时时间,修复异步代码 |
| Act 警告 | 在 act 外更新状态 | 使用 userEvent 或在 act() 中包装 |
| 测试不稳定 | 竞态条件、时序问题 | 使用恰当的异步查询(findBy、waitFor) |
| 无法测试钩子 | 测试实现细节 | 提取到组件中,或使用 renderHook |
| 模拟不起作用 | 导入后才进行模拟 | 在导入组件前进行模拟 |
| 测试相互影响 | 共享状态 | 在 afterEach 中清理 |
参考文档
- Testing Library
- Vitest
- MSW
- MCP:
mcp__documentation__fetch_docs→ technology:react,topic:testing
评论
支持 Markdown