mcpField
R
react-testing-patterns

react-testing-patterns

github.com/hieutrtr测试与质量8137 次安装38 周前更新
代码仓库

关于 react-testing-patterns

为 React 开发者提供组件、Hook 和异步状态测试指导的技能。它强调验证用户可观察的行为,并涵盖 MSW API 模拟、无障碍检查及测试工具设置。

主要功能

  1. 01按用户行为编写组件测试
  2. 02使用 userEvent 模拟交互
  3. 03用 MSW 模拟 API 请求
  4. 04测试 Hook 与异步状态
  5. 05通过 jest-axe 检查无障碍
  6. 06设置带 Provider 的自定义渲染

适用场景

  1. 01测试表单提交和组件交互
  2. 02覆盖 API 加载、成功与错误状态
  3. 03验证自定义 Hook 的行为
  4. 04检查组件的无障碍问题

安装

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

$npx skills add https://github.com/hieutrtr/ai1-skills --skill react-testing-patterns

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

包含的文件

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

references/
component-test-template.tsx
hook-test-template.tsx
msw-handler-examples.ts

详细介绍

React 测试模式

适用场景

在以下情况下启用此技能:

  • 为 React 组件编写测试(渲染、交互、无障碍性)
  • 使用 renderHook 测试自定义钩子
  • 使用 MSW(Mock Service Worker)模拟 API 调用
  • 测试异步状态变化(加载、错误、成功)
  • 使用 jest-axe 审查组件的无障碍性
  • 搭建测试基础设施(providers、测试工具)

以下情况不要使用此技能:

  • 使用 Playwright 编写端到端浏览器测试(使用 e2e-testing)
  • 编写 Python 后端测试(使用 pytest-patterns)
  • 强制执行 TDD 工作流(使用 tdd-workflow)
  • 编写组件实现代码(使用 react-frontend-expert)

指南

Testing Library 理念

核心原则: 测试行为,而不是实现。

查询优先级(优先使用列表靠前的查询):

  1. getByRole — 无障碍角色(button、heading、textbox)
  2. getByLabelText — 带标签的表单元素
  3. getByPlaceholderText — 输入框占位符
  4. getByText — 可见文本内容
  5. getByDisplayValue — 当前表单输入值
  6. getByAltText — 图片
  7. getByTestId — 最后的选择(data-testid 属性)

交互: 始终优先使用 userEvent,而非 fireEvent:

import userEvent from "@testing-library/user-event";

// Good — simulates real user behavior
const user = userEvent.setup();
await user.click(button);
await user.type(input, "hello");

// Bad — low-level event dispatch
fireEvent.click(button);

不要测试的内容:

  • 组件内部状态(不要直接测试 useState 的值)
  • CSS 类或样式
  • 组件实例方法
  • 调用了哪些钩子
  • 动态内容的快照测试
  • 第三方库的内部实现
组件测试结构

每个组件测试都遵循 Arrange → Act → Assert:

import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { axe } from "jest-axe";
import { UserCard } from "./UserCard";

describe("UserCard", () => {
  const defaultProps = {
    user: { id: 1, displayName: "Alice", email: "[email protected]" },
    onEdit: vi.fn(),
  };

  it("renders user name", () => {
    // Arrange
    render(<UserCard {...defaultProps} />);
    // Assert
    expect(screen.getByText("Alice")).toBeInTheDocument();
  });

  it("calls onEdit when edit button is clicked", async () => {
    // Arrange
    const user = userEvent.setup();
    render(<UserCard {...defaultProps} />);
    // Act
    await user.click(screen.getByRole("button", { name: /edit/i }));
    // Assert
    expect(defaultProps.onEdit).toHaveBeenCalledWith(1);
  });

  it("has no accessibility violations", async () => {
    const { container } = render(<UserCard {...defaultProps} />);
    expect(await axe(container)).toHaveNoViolations();
  });
});
异步测试
waitFor(等待状态更新)
it("shows user data after loading", async () => {
  render(<UserProfile userId={1} />);

  // Loading state
  expect(screen.getByText(/loading/i)).toBeInTheDocument();

  // Wait for data to appear
  await waitFor(() => {
    expect(screen.getByText("Alice")).toBeInTheDocument();
  });

  // Loading state gone
  expect(screen.queryByText(/loading/i)).not.toBeInTheDocument();
});
findBy(内置 waitFor)
it("shows user data after loading", async () => {
  render(<UserProfile userId={1} />);

  // findBy = getBy + waitFor — preferred for async appearance
  const heading = await screen.findByRole("heading", { name: "Alice" });
  expect(heading).toBeInTheDocument();
});

对于异步出现的元素,优先使用 findBy*,而非 waitFor + getBy*。

测试错误状态
it("shows error message on API failure", async () => {
  // Override MSW handler for this test
  server.use(
    http.get("/api/users/:id", () => {
      return HttpResponse.json(
        { detail: "User not found" },
        { status: 404 },
      );
    }),
  );

  render(<UserProfile userId={999} />);

  const error = await screen.findByRole("alert");
  expect(error).toHaveTextContent(/not found/i);
});
MSW API 模拟

为所有 API 测试设置模拟服务器:

// test/mocks/handlers.ts
import { http, HttpResponse } from "msw";

export const handlers = [
  http.get("/api/users", () => {
    return HttpResponse.json({
      items: [
        { id: 1, displayName: "Alice", email: "[email protected]" },
        { id: 2, displayName: "Bob", email: "[email protected]" },
      ],
      next_cursor: null,
      has_more: false,
    });
  }),

  http.get("/api/users/:id", ({ params }) => {
    return HttpResponse.json({
      id: Number(params.id),
      displayName: "Alice",
      email: "[email protected]",
    });
  }),

  http.post("/api/users", async ({ request }) => {
    const body = await request.json();
    return HttpResponse.json(
      { id: 3, ...body, created_at: new Date().toISOString() },
      { status: 201 },
    );
  }),
];
// test/mocks/server.ts
import { setupServer } from "msw/node";
import { handlers } from "./handlers";

export const server = setupServer(...handlers);
// test/setup.ts (Vitest setup file)
import { server } from "./mocks/server";

beforeAll(() => server.listen({ onUnhandledRequest: "error" }));
afterEach(() => server.resetHandlers());
afterAll(() => server.close());

按测试覆盖处理器:

server.use(
  http.get("/api/users", () => {
    return HttpResponse.json({ items: [], next_cursor: null, has_more: false });
  }),
);
钩子测试
import { renderHook, act } from "@testing-library/react";
import { useDebounce } from "./useDebounce";

describe("useDebounce", () => {
  beforeEach(() => { vi.useFakeTimers(); });
  afterEach(() => { vi.useRealTimers(); });

  it("returns initial value immediately", () => {
    const { result } = renderHook(() => useDebounce("hello", 300));
    expect(result.current).toBe("hello");
  });

  it("debounces value changes", () => {
    const { result, rerender } = renderHook(
      ({ value }) => useDebounce(value, 300),
      { initialProps: { value: "hello" } },
    );

    rerender({ value: "world" });
    expect(result.current).toBe("hello"); // Still old value

    act(() => { vi.advanceTimersByTime(300); });
    expect(result.current).toBe("world"); // Now updated
  });
});

使用 TanStack Query 测试钩子:

import { QueryClient, QueryClientProvider } from "@tanstack/react-query";

function createWrapper() {
  const queryClient = new QueryClient({
    defaultOptions: { queries: { retry: false } },
  });
  return ({ children }) => (
    <QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
  );
}

it("fetches users", async () => {
  const { result } = renderHook(() => useUsers(), { wrapper: createWrapper() });
  await waitFor(() => expect(result.current.isSuccess).toBe(true));
  expect(result.current.data).toHaveLength(2);
});
无障碍性测试

将以下内容添加到每个组件测试文件中:

import { axe, toHaveNoViolations } from "jest-axe";

expect.extend(toHaveNoViolations);

it("has no accessibility violations", async () => {
  const { container } = render(<UserCard {...defaultProps} />);
  const results = await axe(container);
  expect(results).toHaveNoViolations();
});
测试工具:自定义渲染

创建一个自定义渲染函数,用所需的 providers 包装组件:

// test/utils.tsx
import { render, RenderOptions } from "@testing-library/react";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { MemoryRouter } from "react-router-dom";
import { AuthProvider } from "@/contexts/AuthContext";

function AllProviders({ children }: { children: React.ReactNode }) {
  const queryClient = new QueryClient({
    defaultOptions: { queries: { retry: false, gcTime: 0 } },
  });
  return (
    <QueryClientProvider client={queryClient}>
      <MemoryRouter>
        <AuthProvider>{children}</AuthProvider>
      </MemoryRouter>
    </QueryClientProvider>
  );
}

export function renderWithProviders(ui: React.ReactElement, options?: RenderOptions) {
  return render(ui, { wrapper: AllProviders, ...options });
}

示例

测试表单组件
describe("CreateUserForm", () => {
  it("submits valid data", async () => {
    const onSubmit = vi.fn();
    const user = userEvent.setup();
    render(<CreateUserForm onSubmit={onSubmit} />);

    await user.type(screen.getByLabelText(/email/i), "[email protected]");
    await user.type(screen.getByLabelText(/name/i), "Test User");
    await user.click(screen.getByRole("button", { name: /create/i }));

    expect(onSubmit).toHaveBeenCalledWith({
      email: "[email protected]",
      displayName: "Test User",
      role: "member",
    });
  });

  it("shows validation errors for empty required fields", async () => {
    const user = userEvent.setup();
    render(<CreateUserForm onSubmit={vi.fn()} />);
    await user.click(screen.getByRole("button", { name: /create/i }));

    expect(await screen.findByText(/required/i)).toBeInTheDocument();
  });
});

边缘情况

  • 带 providers 的组件: 始终使用自定义渲染函数,用 QueryClientProvider、MemoryRouter 以及所需的任何 context providers 包装组件。

  • 带路由器的组件: 对于使用 useParams 或 useNavigate 的组件,使用 <MemoryRouter initialEntries={["/users/1"]}>。

  • 不稳定的异步测试: 优先使用 findBy*,而非 waitFor + getBy*。如果使用 waitFor,请为 CI 增加超时时间:waitFor(() => ..., { timeout: 5000 })。

  • 测试模态框/portal: 使用 screen 查询(它们会搜索整个文档),而非 container 查询。

  • 清理: Testing Library 会在每个测试后自动清理。除非使用自定义设置,否则不要手动调用 cleanup()。

参见 references/component-test-template.tsx,查看带注释的测试文件模板。
参见 references/msw-handler-examples.ts,查看 MSW 处理器模式。
参见 references/hook-test-template.tsx,查看钩子测试模式。

评论

支持 Markdown