关于 react-testing-patterns
为 React 开发者提供组件、Hook 和异步状态测试指导的技能。它强调验证用户可观察的行为,并涵盖 MSW API 模拟、无障碍检查及测试工具设置。
主要功能
- 01按用户行为编写组件测试
- 02使用 userEvent 模拟交互
- 03用 MSW 模拟 API 请求
- 04测试 Hook 与异步状态
- 05通过 jest-axe 检查无障碍
- 06设置带 Provider 的自定义渲染
适用场景
- 01测试表单提交和组件交互
- 02覆盖 API 加载、成功与错误状态
- 03验证自定义 Hook 的行为
- 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.tsxhook-test-template.tsxmsw-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 理念
核心原则: 测试行为,而不是实现。
查询优先级(优先使用列表靠前的查询):
getByRole— 无障碍角色(button、heading、textbox)getByLabelText— 带标签的表单元素getByPlaceholderText— 输入框占位符getByText— 可见文本内容getByDisplayValue— 当前表单输入值getByAltText— 图片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,查看钩子测试模式。