mcpField
T
testing-library

testing-library

github.com/jezweb测试与质量1.1k203 次安装3 周前更新
代码仓库

关于 testing-library

面向 React 开发者的 React Testing Library 测试指南,介绍可访问性优先的查询、用户交互和异步测试,并包含 MSW API 模拟示例。

主要功能

  1. 01按可访问性优先级选择查询
  2. 02使用 userEvent 模拟交互
  3. 03测试异步界面变化
  4. 04通过 MSW 模拟 API
  5. 05检查可访问性问题
  6. 06表单、弹窗与列表测试模式

适用场景

  1. 01验证表单提交与校验
  2. 02测试异步加载和弹窗
  3. 03模拟 API 成功或错误响应
  4. 04检查组件的可访问性

安装

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

$npx skills add https://github.com/jezweb/claude-skills --skill testing-library

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

包含的文件

除 SKILL.md 外,安装时一起下载的参考资料和脚本。

README.md
.claude-plugin/
plugin.json
rules/
testing-library-patterns.md
templates/
msw-handlers.ts
setup.ts

详细介绍

React Testing Library

状态:已可用于生产环境 最后更新:2026-02-06 版本:16.x User Event:14.x


快速开始

# Install with Vitest
pnpm add -D @testing-library/react @testing-library/user-event @testing-library/jest-dom jsdom

# Or with Jest
pnpm add -D @testing-library/react @testing-library/user-event @testing-library/jest-dom
设置文件(src/test/setup.ts)
import '@testing-library/jest-dom/vitest';
import { cleanup } from '@testing-library/react';
import { afterEach } from 'vitest';

// Cleanup after each test
afterEach(() => {
  cleanup();
});
Vitest 配置
// vitest.config.ts
export default defineConfig({
  test: {
    globals: true,
    environment: 'jsdom',
    setupFiles: ['./src/test/setup.ts'],
  },
});

查询优先级(无障碍优先)

为了编写无障碍且可靠的测试,请按以下顺序使用查询:

优先级查询用途
1getByRole按钮、链接、标题、输入框
2getByLabelText带标签的表单输入框
3getByPlaceholderText没有可见标签的输入框
4getByText非交互文本内容
5getByTestId仅作为最后手段
示例
import { render, screen } from '@testing-library/react';

// ✅ GOOD - semantic role queries
screen.getByRole('button', { name: /submit/i });
screen.getByRole('heading', { level: 1 });
screen.getByRole('textbox', { name: /email/i });
screen.getByRole('link', { name: /learn more/i });

// ✅ GOOD - label-based queries for forms
screen.getByLabelText(/email address/i);

// ⚠️ OK - when no better option
screen.getByText(/welcome to our app/i);

// ❌ AVOID - not accessible, brittle
screen.getByTestId('submit-button');

查询变体

变体返回值是否抛出异常用途
getBy元素是元素当前存在
queryBy元素或 null否元素可能不存在
findByPromise是异步等待稍后出现的元素
getAllByElement[]是多个元素
queryAllByElement[]否多个元素或无元素
findAllByPromise<Element[]>是异步等待多个元素
如何选择
// Element exists immediately
const button = screen.getByRole('button');

// Check element doesn't exist
expect(screen.queryByRole('dialog')).not.toBeInTheDocument();

// Wait for async element to appear
const modal = await screen.findByRole('dialog');

// Multiple elements
const items = screen.getAllByRole('listitem');

User Event(模拟真实交互)

始终优先使用 userEvent,而不是 fireEvent ——它会模拟真实用户行为。

import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';

describe('Form', () => {
  it('submits form data', async () => {
    const user = userEvent.setup();
    const onSubmit = vi.fn();

    render(<LoginForm onSubmit={onSubmit} />);

    // 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(onSubmit).toHaveBeenCalledWith({
      email: '[email protected]',
      password: 'secret123',
    });
  });
});
常见用户事件
const user = userEvent.setup();

// Clicking
await user.click(element);
await user.dblClick(element);
await user.tripleClick(element); // Select all text

// Typing
await user.type(input, 'hello world');
await user.clear(input);
await user.type(input, '{Enter}'); // Special keys

// Keyboard
await user.keyboard('{Shift>}A{/Shift}'); // Shift+A
await user.tab(); // Tab navigation

// Selection
await user.selectOptions(select, ['option1', 'option2']);

// Hover
await user.hover(element);
await user.unhover(element);

// Clipboard
await user.copy();
await user.paste();

异步测试

findBy——等待元素
it('shows loading then content', async () => {
  render(<AsyncComponent />);

  // Shows loading initially
  expect(screen.getByText(/loading/i)).toBeInTheDocument();

  // Wait for content to appear (auto-retries)
  const content = await screen.findByText(/data loaded/i);
  expect(content).toBeInTheDocument();
});
waitFor——等待条件
import { waitFor } from '@testing-library/react';

it('updates count after click', async () => {
  const user = userEvent.setup();
  render(<Counter />);

  await user.click(screen.getByRole('button', { name: /increment/i }));

  // Wait for state update
  await waitFor(() => {
    expect(screen.getByText(/count: 1/i)).toBeInTheDocument();
  });
});
waitForElementToBeRemoved
import { waitForElementToBeRemoved } from '@testing-library/react';

it('hides modal after close', async () => {
  const user = userEvent.setup();
  render(<ModalComponent />);

  await user.click(screen.getByRole('button', { name: /close/i }));

  // Wait for modal to disappear
  await waitForElementToBeRemoved(() => screen.queryByRole('dialog'));
});

MSW 集成(API 模拟)

使用 Mock Service Worker 在网络层模拟 API 调用。

pnpm add -D msw
设置(src/test/mocks/handlers.ts)
import { http, HttpResponse } from 'msw';

export const handlers = [
  http.get('/api/user', () => {
    return HttpResponse.json({
      id: 1,
      name: 'Test User',
      email: '[email protected]',
    });
  }),

  http.post('/api/login', async ({ request }) => {
    const body = await request.json();
    if (body.password === 'correct') {
      return HttpResponse.json({ token: 'abc123' });
    }
    return HttpResponse.json(
      { error: 'Invalid credentials' },
      { status: 401 }
    );
  }),
];
设置(src/test/mocks/server.ts)
import { setupServer } from 'msw/node';
import { handlers } from './handlers';

export const server = setupServer(...handlers);
测试设置
// src/test/setup.ts
import { server } from './mocks/server';
import { beforeAll, afterEach, afterAll } from 'vitest';

beforeAll(() => server.listen({ onUnhandledRequest: 'error' }));
afterEach(() => server.resetHandlers());
afterAll(() => server.close());
在测试中使用
import { server } from '../test/mocks/server';
import { http, HttpResponse } from 'msw';

it('handles API error', async () => {
  // Override handler for this test
  server.use(
    http.get('/api/user', () => {
      return HttpResponse.json(
        { error: 'Server error' },
        { status: 500 }
      );
    })
  );

  render(<UserProfile />);

  await screen.findByText(/error loading user/i);
});

无障碍测试

检查无障碍违规
pnpm add -D @axe-core/react
import { axe, toHaveNoViolations } from 'jest-axe';

expect.extend(toHaveNoViolations);

it('has no accessibility violations', async () => {
  const { container } = render(<MyComponent />);
  const results = await axe(container);
  expect(results).toHaveNoViolations();
});
基于角色的查询也是无障碍测试

使用 getByRole 会隐式测试无障碍性:

// This passes only if button is properly accessible
screen.getByRole('button', { name: /submit/i });

// Fails if:
// - Element isn't a button or role="button"
// - Accessible name doesn't match
// - Element is hidden from accessibility tree

测试模式

表单
it('validates required fields', async () => {
  const user = userEvent.setup();
  render(<ContactForm />);

  // Submit without filling required fields
  await user.click(screen.getByRole('button', { name: /submit/i }));

  // Check for validation errors
  expect(screen.getByText(/email is required/i)).toBeInTheDocument();
  expect(screen.getByText(/message is required/i)).toBeInTheDocument();
});
模态框/对话框
it('opens and closes modal', async () => {
  const user = userEvent.setup();
  render(<ModalTrigger />);

  // Modal not visible initially
  expect(screen.queryByRole('dialog')).not.toBeInTheDocument();

  // Open modal
  await user.click(screen.getByRole('button', { name: /open/i }));
  expect(screen.getByRole('dialog')).toBeInTheDocument();

  // Close modal
  await user.click(screen.getByRole('button', { name: /close/i }));
  await waitForElementToBeRemoved(() => screen.queryByRole('dialog'));
});
列表
it('renders list items', () => {
  render(<TodoList items={['Buy milk', 'Walk dog']} />);

  const items = screen.getAllByRole('listitem');
  expect(items).toHaveLength(2);
  expect(items[0]).toHaveTextContent('Buy milk');
});

常用匹配器(jest-dom)

// Presence
expect(element).toBeInTheDocument();
expect(element).toBeVisible();
expect(element).toBeEmptyDOMElement();

// State
expect(button).toBeEnabled();
expect(button).toBeDisabled();
expect(checkbox).toBeChecked();
expect(input).toBeRequired();

// Content
expect(element).toHaveTextContent(/hello/i);
expect(element).toHaveValue('test');
expect(element).toHaveAttribute('href', '/about');

// Styles
expect(element).toHaveClass('active');
expect(element).toHaveStyle({ color: 'red' });

// Focus
expect(input).toHaveFocus();

调试

screen.debug()
it('debugs rendering', () => {
  render(<MyComponent />);

  // Print entire DOM
  screen.debug();

  // Print specific element
  screen.debug(screen.getByRole('button'));
});
logRoles
import { logRoles } from '@testing-library/react';

it('shows available roles', () => {
  const { container } = render(<MyComponent />);
  logRoles(container);
});

常见错误

对异步操作使用 getBy
// ❌ WRONG - fails if element appears async
const modal = screen.getByRole('dialog');

// ✅ CORRECT - waits for element
const modal = await screen.findByRole('dialog');
未等待用户事件完成
// ❌ WRONG - race condition
user.click(button);
expect(result).toBeInTheDocument();

// ✅ CORRECT - await the interaction
await user.click(button);
expect(result).toBeInTheDocument();
使用 container.querySelector
// ❌ WRONG - not accessible, brittle
const button = container.querySelector('.submit-btn');

// ✅ CORRECT - accessible query
const button = screen.getByRole('button', { name: /submit/i });

另请参阅

评论

支持 Markdown