重要前提
安装AI Skills的关键前提是:必须科学上网,且开启TUN模式,这一点至关重要,直接决定安装能否顺利完成,在此郑重提醒三遍:科学上网,科学上网,科学上网。查看完整安装教程 →
react-testing-patterns by hieutrtr/ai1-skills
npx skills add https://github.com/hieutrtr/ai1-skills --skill react-testing-patterns在以下场景激活此技能:
renderHook 测试自定义钩子不要在以下场景使用此技能:
e2e-testing)pytest-patterns)tdd-workflow)react-frontend-expert)核心原则: 测试行为,而非实现。
查询优先级(优先使用列表中靠前的):
广告位招租
在这里展示您的产品或服务
触达数万 AI 开发者,精准高效
getByRole — 可访问角色(按钮、标题、文本框)getByLabelText — 带标签的表单元素getByPlaceholderText — 输入框占位符getByText — 可见文本内容getByDisplayValue — 当前表单输入值getByAltText — 图片getByTestId — 最后手段(data-testid 属性)交互: 始终使用 userEvent 而非 fireEvent:
import userEvent from "@testing-library/user-event";
// 好 — 模拟真实用户行为
const user = userEvent.setup();
await user.click(button);
await user.type(input, "hello");
// 不好 — 低级事件派发
fireEvent.click(button);
不应测试的内容:
useState 值)每个组件测试遵循 准备 → 执行 → 断言 模式:
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: "alice@example.com" },
onEdit: vi.fn(),
};
it("渲染用户姓名", () => {
// 准备
render(<UserCard {...defaultProps} />);
// 断言
expect(screen.getByText("Alice")).toBeInTheDocument();
});
it("点击编辑按钮时调用 onEdit", async () => {
// 准备
const user = userEvent.setup();
render(<UserCard {...defaultProps} />);
// 执行
await user.click(screen.getByRole("button", { name: /edit/i }));
// 断言
expect(defaultProps.onEdit).toHaveBeenCalledWith(1);
});
it("没有可访问性违规", async () => {
const { container } = render(<UserCard {...defaultProps} />);
expect(await axe(container)).toHaveNoViolations();
});
});
it("加载后显示用户数据", async () => {
render(<UserProfile userId={1} />);
// 加载状态
expect(screen.getByText(/loading/i)).toBeInTheDocument();
// 等待数据出现
await waitFor(() => {
expect(screen.getByText("Alice")).toBeInTheDocument();
});
// 加载状态消失
expect(screen.queryByText(/loading/i)).not.toBeInTheDocument();
});
it("加载后显示用户数据", async () => {
render(<UserProfile userId={1} />);
// findBy = getBy + waitFor — 推荐用于异步出现的元素
const heading = await screen.findByRole("heading", { name: "Alice" });
expect(heading).toBeInTheDocument();
});
对于异步出现的元素,优先使用 findBy* 而非 waitFor + getBy*。
it("API 失败时显示错误消息", async () => {
// 为此测试覆盖 MSW 处理器
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);
});
为所有 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: "alice@example.com" },
{ id: 2, displayName: "Bob", email: "bob@example.com" },
],
next_cursor: null,
has_more: false,
});
}),
http.get("/api/users/:id", ({ params }) => {
return HttpResponse.json({
id: Number(params.id),
displayName: "Alice",
email: "alice@example.com",
});
}),
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 设置文件)
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("立即返回初始值", () => {
const { result } = renderHook(() => useDebounce("hello", 300));
expect(result.current).toBe("hello");
});
it("对值变化进行防抖", () => {
const { result, rerender } = renderHook(
({ value }) => useDebounce(value, 300),
{ initialProps: { value: "hello" } },
);
rerender({ value: "world" });
expect(result.current).toBe("hello"); // 仍然是旧值
act(() => { vi.advanceTimersByTime(300); });
expect(result.current).toBe("world"); // 现在已更新
});
});
测试带有 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("获取用户", 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("没有可访问性违规", async () => {
const { container } = render(<UserCard {...defaultProps} />);
const results = await axe(container);
expect(results).toHaveNoViolations();
});
创建一个自定义渲染函数,用所需的提供者包装组件:
// 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("提交有效数据", async () => {
const onSubmit = vi.fn();
const user = userEvent.setup();
render(<CreateUserForm onSubmit={onSubmit} />);
await user.type(screen.getByLabelText(/email/i), "test@example.com");
await user.type(screen.getByLabelText(/name/i), "Test User");
await user.click(screen.getByRole("button", { name: /create/i }));
expect(onSubmit).toHaveBeenCalledWith({
email: "test@example.com",
displayName: "Test User",
role: "member",
});
});
it("为空必填字段显示验证错误", 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();
});
});
带有提供者的组件: 始终使用自定义渲染函数,用 QueryClientProvider、MemoryRouter 和任何需要的上下文提供者包装组件。
带有路由器的组件: 对于使用 useParams 或 useNavigate 的组件,使用 <MemoryRouter initialEntries={["/users/1"]}>。
不稳定的异步测试: 优先使用 findBy* 而非 waitFor + getBy*。如果使用 waitFor,为 CI 增加超时时间:waitFor(() => ..., { timeout: 5000 })。
测试模态框/传送门: 使用 screen 查询(它们搜索整个文档),而非 container 查询。
清理: Testing Library 会在每次测试后自动清理。除非使用自定义设置,否则不要手动调用 cleanup()。
查看 references/component-test-template.tsx 获取带注释的测试文件模板。查看 references/msw-handler-examples.ts 获取 MSW 处理器模式。查看 references/hook-test-template.tsx 获取钩子测试模式。
每周安装次数
49
仓库
GitHub 星标数
8
首次出现
2026年2月4日
安全审计
安装于
codex45
github-copilot43
opencode42
gemini-cli42
claude-code37
cursor36
Activate this skill when:
renderHookDo NOT use this skill for:
e2e-testing)pytest-patterns)tdd-workflow)react-frontend-expert)Core principle: Test behavior, not implementation.
Query priority (prefer higher in the list):
getByRole — accessible role (button, heading, textbox)getByLabelText — form elements with labelsgetByPlaceholderText — input placeholdersgetByText — visible text contentgetByDisplayValue — current form input valuegetByAltText — imagesgetByTestId — last resort (data-testid attribute)Interaction: Always use userEvent over 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);
What NOT to test:
useState values directly)Every component test follows 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: "alice@example.com" },
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();
});
});
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();
});
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();
});
PreferfindBy* over waitFor + getBy* for elements that appear asynchronously.
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);
});
Setup a mock server for all API tests:
// 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: "alice@example.com" },
{ id: 2, displayName: "Bob", email: "bob@example.com" },
],
next_cursor: null,
has_more: false,
});
}),
http.get("/api/users/:id", ({ params }) => {
return HttpResponse.json({
id: Number(params.id),
displayName: "Alice",
email: "alice@example.com",
});
}),
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());
Per-test handler override:
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
});
});
Testing hooks with 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);
});
Add to every component test file:
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();
});
Create a custom render that wraps components with required 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), "test@example.com");
await user.type(screen.getByLabelText(/name/i), "Test User");
await user.click(screen.getByRole("button", { name: /create/i }));
expect(onSubmit).toHaveBeenCalledWith({
email: "test@example.com",
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();
});
});
Components with providers: Always use a custom render function that wraps components with QueryClientProvider, MemoryRouter, and any context providers needed.
Components with router: Use <MemoryRouter initialEntries={["/users/1"]}> for components that use useParams or useNavigate.
Flaky async tests: Prefer findBy* over waitFor + getBy*. If using waitFor, increase timeout for CI: .
See references/component-test-template.tsx for an annotated test file template. See references/msw-handler-examples.ts for MSW handler patterns. See references/hook-test-template.tsx for hook testing patterns.
Weekly Installs
49
Repository
GitHub Stars
8
First Seen
Feb 4, 2026
Security Audits
Gen Agent Trust HubPassSocketPassSnykPass
Installed on
codex45
github-copilot43
opencode42
gemini-cli42
claude-code37
cursor36
后端测试指南:API端点、业务逻辑与数据库测试最佳实践
11,800 周安装
waitFor(() => ..., { timeout: 5000 })Testing modals/portals: Use screen queries (they search the entire document), not container queries.
Cleanup: Testing Library auto-cleans after each test. Don't call cleanup() manually unless using a custom setup.