mcpField
R
react-testing

react-testing

github.com/claude-dev-suite测试与质量4641 次安装3 天前更新
代码仓库

关于 react-testing

面向 React 开发者的测试参考,涵盖使用 Vitest 和 Testing Library 测试组件行为、用户交互、异步更新与模拟。

主要功能

  1. 01Vitest 和 Testing Library 配置
  2. 02按无障碍语义查询元素
  3. 03测试用户交互
  4. 04测试异步状态与更新
  5. 05模拟函数和模块

适用场景

  1. 01通过渲染界面测试组件行为
  2. 02检查表单交互和提交
  3. 03覆盖加载过程与异步数据
  4. 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 中清理

参考文档

评论

支持 Markdown