testing-helper
Compare original and translation side by side
🇺🇸
Original
English🇨🇳
Translation
ChineseTesting Helper
测试助手
Master testing for React and React Router v7 applications. Learn how to write effective tests using Vitest and React Testing Library.
掌握React和React Router v7应用的测试方法,学习如何使用Vitest和React Testing Library编写高效的测试用例。
Quick Reference
快速参考
Basic Component Test
基础组件测试
typescript
import { render, screen } from "@testing-library/react";
import { expect, test } from "vitest";
test("renders button", () => {
render(<Button>Click me</Button>);
expect(screen.getByText("Click me")).toBeInTheDocument();
});typescript
import { render, screen } from "@testing-library/react";
import { expect, test } from "vitest";
test("renders button", () => {
render(<Button>Click me</Button>);
expect(screen.getByText("Click me")).toBeInTheDocument();
});Test User Interactions
用户交互测试
typescript
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
test("handles click", async () => {
const user = userEvent.setup();
const handleClick = vi.fn();
render(<Button onClick={handleClick}>Click</Button>);
await user.click(screen.getByRole("button"));
expect(handleClick).toHaveBeenCalledOnce();
});typescript
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
test("handles click", async () => {
const user = userEvent.setup();
const handleClick = vi.fn();
render(<Button onClick={handleClick}>Click</Button>);
await user.click(screen.getByRole("button"));
expect(handleClick).toHaveBeenCalledOnce();
});Test Loaders
Loader测试
typescript
import { loader } from "./route";
test("loader fetches user", async () {
const result = await loader({
params: { userId: "123" },
request: new Request("http://localhost"),
context: {},
});
expect(result.user).toBeDefined();
});typescript
import { loader } from "./route";
test("loader fetches user", async () {
const result = await loader({
params: { userId: "123" },
request: new Request("http://localhost"),
context: {},
});
expect(result.user).toBeDefined();
});When to Use This Skill
适用场景
- Setting up testing infrastructure
- Writing component tests
- Testing loaders and actions
- Mocking API calls
- Testing user interactions
- Testing forms and validation
- Integration testing routes
- 搭建测试基础设施
- 编写组件测试
- 测试loader和action
- 模拟API调用
- 测试用户交互
- 测试表单与校验
- 路由集成测试
Setup
环境搭建
Install Dependencies
安装依赖
bash
npm install -D vitest @testing-library/react @testing-library/user-event @testing-library/jest-dom jsdombash
npm install -D vitest @testing-library/react @testing-library/user-event @testing-library/jest-dom jsdomConfigure Vitest
配置Vitest
typescript
// vitest.config.ts
import { defineConfig } from "vitest/config";
import react from "@vitejs/plugin-react";
export default defineConfig({
plugins: [react()],
test: {
environment: "jsdom",
setupFiles: ["./test/setup.ts"],
globals: true,
},
});typescript
// vitest.config.ts
import { defineConfig } from "vitest/config";
import react from "@vitejs/plugin-react";
export default defineConfig({
plugins: [react()],
test: {
environment: "jsdom",
setupFiles: ["./test/setup.ts"],
globals: true,
},
});Test Setup File
测试启动文件
typescript
// test/setup.ts
import "@testing-library/jest-dom";
import { expect, afterEach } from "vitest";
import { cleanup } from "@testing-library/react";
// Cleanup after each test
afterEach(() => {
cleanup();
});typescript
// test/setup.ts
import "@testing-library/jest-dom";
import { expect, afterEach } from "vitest";
import { cleanup } from "@testing-library/react";
// 每个测试结束后清理
afterEach(() => {
cleanup();
});Component Testing
组件测试
1. Basic Rendering
1. 基础渲染
typescript
import { render, screen } from "@testing-library/react";
import { describe, test, expect } from "vitest";
import { UserCard } from "./UserCard";
describe("UserCard", () => {
test("renders user information", () => {
const user = {
id: "1",
name: "John Doe",
email: "john@example.com",
};
render(<UserCard user={user} />);
expect(screen.getByText("John Doe")).toBeInTheDocument();
expect(screen.getByText("john@example.com")).toBeInTheDocument();
});
test("displays avatar when provided", () => {
const user = {
id: "1",
name: "John Doe",
email: "john@example.com",
avatar: "https://example.com/avatar.jpg",
};
render(<UserCard user={user} />);
const avatar = screen.getByRole("img", { name: "John Doe" });
expect(avatar).toHaveAttribute("src", user.avatar);
});
});typescript
import { render, screen } from "@testing-library/react";
import { describe, test, expect } from "vitest";
import { UserCard } from "./UserCard";
describe("UserCard", () => {
test("renders user information", () => {
const user = {
id: "1",
name: "John Doe",
email: "john@example.com",
};
render(<UserCard user={user} />);
expect(screen.getByText("John Doe")).toBeInTheDocument();
expect(screen.getByText("john@example.com")).toBeInTheDocument();
});
test("displays avatar when provided", () => {
const user = {
id: "1",
name: "John Doe",
email: "john@example.com",
avatar: "https://example.com/avatar.jpg",
};
render(<UserCard user={user} />);
const avatar = screen.getByRole("img", { name: "John Doe" });
expect(avatar).toHaveAttribute("src", user.avatar);
});
});2. User Interactions
2. 用户交互
typescript
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { vi } from "vitest";
test("calls onClick when button is clicked", async () => {
const user = userEvent.setup();
const handleClick = vi.fn();
render(<Button onClick={handleClick}>Click me</Button>);
await user.click(screen.getByRole("button", { name: "Click me" }));
expect(handleClick).toHaveBeenCalledOnce();
});
test("types in input field", async () => {
const user = userEvent.setup();
const handleChange = vi.fn();
render(<input onChange={handleChange} />);
await user.type(screen.getByRole("textbox"), "Hello");
expect(screen.getByRole("textbox")).toHaveValue("Hello");
expect(handleChange).toHaveBeenCalledTimes(5); // Once per character
});typescript
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { vi } from "vitest";
test("calls onClick when button is clicked", async () => {
const user = userEvent.setup();
const handleClick = vi.fn();
render(<Button onClick={handleClick}>Click me</Button>);
await user.click(screen.getByRole("button", { name: "Click me" }));
expect(handleClick).toHaveBeenCalledOnce();
});
test("types in input field", async () => {
const user = userEvent.setup();
const handleChange = vi.fn();
render(<input onChange={handleChange} />);
await user.type(screen.getByRole("textbox"), "Hello");
expect(screen.getByRole("textbox")).toHaveValue("Hello");
expect(handleChange).toHaveBeenCalledTimes(5); // 每个字符触发一次
});3. Async Operations
3. 异步操作
typescript
import { render, screen, waitFor } from "@testing-library/react";
test("loads and displays data", async () => {
render(<UserProfile userId="123" />);
// Initially shows loading
expect(screen.getByText("Loading...")).toBeInTheDocument();
// Wait for data to load
await waitFor(() => {
expect(screen.getByText("John Doe")).toBeInTheDocument();
});
// Loading indicator is gone
expect(screen.queryByText("Loading...")).not.toBeInTheDocument();
});typescript
import { render, screen, waitFor } from "@testing-library/react";
test("loads and displays data", async () => {
render(<UserProfile userId="123" />);
// 初始显示加载状态
expect(screen.getByText("Loading...")).toBeInTheDocument();
// 等待数据加载完成
await waitFor(() => {
expect(screen.getByText("John Doe")).toBeInTheDocument();
});
// 加载提示消失
expect(screen.queryByText("Loading...")).not.toBeInTheDocument();
});Testing React Router
React Router测试
1. Test Loaders
1. Loader测试
typescript
import { loader } from "./route";
import { vi } from "vitest";
// Mock fetch
global.fetch = vi.fn();
test("loader returns user data", async () => {
const mockUser = { id: "123", name: "John" };
(fetch as any).mockResolvedValueOnce({
ok: true,
json: async () => mockUser,
});
const result = await loader({
params: { userId: "123" },
request: new Request("http://localhost/users/123"),
context: {},
});
expect(result).toEqual({ user: mockUser });
expect(fetch).toHaveBeenCalledWith("/api/users/123");
});
test("loader throws on 404", async () => {
(fetch as any).mockResolvedValueOnce({
ok: false,
status: 404,
});
await expect(loader({
params: { userId: "999" },
request: new Request("http://localhost/users/999"),
context: {},
})).rejects.toThrow();
});typescript
import { loader } from "./route";
import { vi } from "vitest";
// Mock fetch
global.fetch = vi.fn();
test("loader returns user data", async () => {
const mockUser = { id: "123", name: "John" };
(fetch as any).mockResolvedValueOnce({
ok: true,
json: async () => mockUser,
});
const result = await loader({
params: { userId: "123" },
request: new Request("http://localhost/users/123"),
context: {},
});
expect(result).toEqual({ user: mockUser });
expect(fetch).toHaveBeenCalledWith("/api/users/123");
});
test("loader throws on 404", async () => {
(fetch as any).mockResolvedValueOnce({
ok: false,
status: 404,
});
await expect(loader({
params: { userId: "999" },
request: new Request("http://localhost/users/999"),
context: {},
})).rejects.toThrow();
});2. Test Actions
2. Action测试
typescript
import { action } from "./route";
test("action creates user on valid data", async () => {
const formData = new FormData();
formData.set("name", "John Doe");
formData.set("email", "john@example.com");
const request = new Request("http://localhost/users", {
method: "POST",
body: formData,
});
const result = await action({
request,
params: {},
context: {},
});
expect(result).toHaveProperty("user");
});
test("action returns errors on invalid data", async () => {
const formData = new FormData();
formData.set("name", "");
formData.set("email", "invalid");
const request = new Request("http://localhost/users", {
method: "POST",
body: formData,
});
const result = await action({
request,
params: {},
context: {},
});
expect(result).toHaveProperty("errors");
expect(result.errors).toHaveProperty("name");
expect(result.errors).toHaveProperty("email");
});typescript
import { action } from "./route";
test("action creates user on valid data", async () => {
const formData = new FormData();
formData.set("name", "John Doe");
formData.set("email", "john@example.com");
const request = new Request("http://localhost/users", {
method: "POST",
body: formData,
});
const result = await action({
request,
params: {},
context: {},
});
expect(result).toHaveProperty("user");
});
test("action returns errors on invalid data", async () => {
const formData = new FormData();
formData.set("name", "");
formData.set("email", "invalid");
const request = new Request("http://localhost/users", {
method: "POST",
body: formData,
});
const result = await action({
request,
params: {},
context: {},
});
expect(result).toHaveProperty("errors");
expect(result.errors).toHaveProperty("name");
expect(result.errors).toHaveProperty("email");
});3. Test Routes with RouterProvider
3. 使用RouterProvider测试路由
typescript
import { render, screen } from "@testing-library/react";
import { createMemoryRouter, RouterProvider } from "react-router";
test("renders route component", async () => {
const router = createMemoryRouter(
[
{
path: "/users/:userId",
element: <UserProfile />,
loader: async () => ({ user: { id: "1", name: "John" } }),
},
],
{
initialEntries: ["/users/1"],
}
);
render(<RouterProvider router={router} />);
await screen.findByText("John");
});typescript
import { render, screen } from "@testing-library/react";
import { createMemoryRouter, RouterProvider } from "react-router";
test("renders route component", async () => {
const router = createMemoryRouter(
[
{
path: "/users/:userId",
element: <UserProfile />,
loader: async () => ({ user: { id: "1", name: "John" } }),
},
],
{
initialEntries: ["/users/1"],
}
);
render(<RouterProvider router={router} />);
await screen.findByText("John");
});Mocking
Mock相关
1. Mock API Calls
1. 模拟API调用
typescript
import { vi } from "vitest";
// Mock fetch globally
global.fetch = vi.fn();
// Mock specific responses
(fetch as any).mockResolvedValue({
ok: true,
json: async () => ({ data: "mocked" }),
});
// Clean up after test
afterEach(() => {
vi.clearAllMocks();
});typescript
import { vi } from "vitest";
// 全局Mock fetch
global.fetch = vi.fn();
// Mock指定返回结果
(fetch as any).mockResolvedValue({
ok: true,
json: async () => ({ data: "mocked" }),
});
// 测试后清理
afterEach(() => {
vi.clearAllMocks();
});2. Mock Modules
2. 模拟模块
typescript
import { vi } from "vitest";
// Mock entire module
vi.mock("./api", () => ({
fetchUser: vi.fn(),
createUser: vi.fn(),
}));
// Import mocked module
import { fetchUser } from "./api";
test("uses mocked API", async () => {
(fetchUser as any).mockResolvedValue({ id: "1", name: "John" });
const user = await fetchUser("1");
expect(user).toEqual({ id: "1", name: "John" });
});typescript
import { vi } from "vitest";
// Mock整个模块
vi.mock("./api", () => ({
fetchUser: vi.fn(),
createUser: vi.fn(),
}));
// 引入Mock后的模块
import { fetchUser } from "./api";
test("uses mocked API", async () => {
(fetchUser as any).mockResolvedValue({ id: "1", name: "John" });
const user = await fetchUser("1");
expect(user).toEqual({ id: "1", name: "John" });
});3. Mock React Router Hooks
3. 模拟React Router Hooks
typescript
import { vi } from "vitest";
import * as ReactRouter from "react-router";
vi.spyOn(ReactRouter, "useNavigate").mockReturnValue(vi.fn());
vi.spyOn(ReactRouter, "useLoaderData").mockReturnValue({
user: { id: "1", name: "John" },
});typescript
import { vi } from "vitest";
import * as ReactRouter from "react-router";
vi.spyOn(ReactRouter, "useNavigate").mockReturnValue(vi.fn());
vi.spyOn(ReactRouter, "useLoaderData").mockReturnValue({
user: { id: "1", name: "John" },
});Form Testing
表单测试
typescript
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { createMemoryRouter, RouterProvider } from "react-router";
test("submits form with valid data", async () => {
const user = userEvent.setup();
const actionSpy = vi.fn().mockResolvedValue({ success: true });
const router = createMemoryRouter(
[
{
path: "/create",
element: <CreateUserForm />,
action: actionSpy,
},
],
{
initialEntries: ["/create"],
}
);
render(<RouterProvider router={router} />);
// Fill form
await user.type(screen.getByLabelText("Name"), "John Doe");
await user.type(screen.getByLabelText("Email"), "john@example.com");
// Submit
await user.click(screen.getByRole("button", { name: "Submit" }));
// Verify action was called
expect(actionSpy).toHaveBeenCalled();
});
test("displays validation errors", async () => {
const user = userEvent.setup();
const actionSpy = vi.fn().mockResolvedValue({
errors: { email: ["Invalid email"] },
});
const router = createMemoryRouter(
[
{
path: "/create",
element: <CreateUserForm />,
action: actionSpy,
},
],
{
initialEntries: ["/create"],
}
);
render(<RouterProvider router={router} />);
await user.type(screen.getByLabelText("Email"), "invalid");
await user.click(screen.getByRole("button", { name: "Submit" }));
await screen.findByText("Invalid email");
});typescript
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { createMemoryRouter, RouterProvider } from "react-router";
test("submits form with valid data", async () => {
const user = userEvent.setup();
const actionSpy = vi.fn().mockResolvedValue({ success: true });
const router = createMemoryRouter(
[
{
path: "/create",
element: <CreateUserForm />,
action: actionSpy,
},
],
{
initialEntries: ["/create"],
}
);
render(<RouterProvider router={router} />);
// 填写表单
await user.type(screen.getByLabelText("Name"), "John Doe");
await user.type(screen.getByLabelText("Email"), "john@example.com");
// 提交
await user.click(screen.getByRole("button", { name: "Submit" }));
// 验证action被调用
expect(actionSpy).toHaveBeenCalled();
});
test("displays validation errors", async () => {
const user = userEvent.setup();
const actionSpy = vi.fn().mockResolvedValue({
errors: { email: ["Invalid email"] },
});
const router = createMemoryRouter(
[
{
path: "/create",
element: <CreateUserForm />,
action: actionSpy,
},
],
{
initialEntries: ["/create"],
}
);
render(<RouterProvider router={router} />);
await user.type(screen.getByLabelText("Email"), "invalid");
await user.click(screen.getByRole("button", { name: "Submit" }));
await screen.findByText("Invalid email");
});Best Practices
最佳实践
- Use queries over destructured render result
screen - Prefer over other query methods
getByRole - Use instead of
userEventfireEvent - Test user behavior, not implementation details
- Mock external dependencies (APIs, modules)
- Clean up after each test
- Use for async assertions
waitFor - Test accessibility with role queries
- Don't test third-party libraries
- Keep tests simple and focused
- 优先使用查询,而非解构render返回的结果
screen - 优先选用,而非其他查询方法
getByRole - 使用代替
userEventfireEvent - 测试用户行为,而非实现细节
- 模拟外部依赖(API、模块)
- 每个测试结束后清理环境
- 异步断言使用
waitFor - 使用role类查询测试可访问性
- 不要测试第三方库
- 保持测试简单、聚焦单一功能
Query Priority
查询优先级
Use queries in this order:
- - Most accessible
getByRole - - Good for forms
getByLabelText - - Form fallback
getByPlaceholderText - - User-visible text
getByText - - Last resort
getByTestId
typescript
// ✅ Best - accessible
screen.getByRole("button", { name: "Submit" });
screen.getByRole("textbox", { name: "Email" });
// ✅ Good - form labels
screen.getByLabelText("Email");
// ⚠️ Okay - if no role/label
screen.getByPlaceholderText("Enter email");
// ⚠️ Fallback
screen.getByText("Submit");
// ❌ Last resort
screen.getByTestId("submit-button");按以下顺序使用查询方法:
- - 可访问性最好
getByRole - - 适合表单场景
getByLabelText - - 表单备选方案
getByPlaceholderText - - 用户可见文本
getByText - - 最后备选方案
getByTestId
typescript
// ✅ 最佳 - 可访问性好
screen.getByRole("button", { name: "Submit" });
screen.getByRole("textbox", { name: "Email" });
// ✅ 良好 - 表单标签场景
screen.getByLabelText("Email");
// ⚠️ 可用 - 无role/标签时使用
screen.getByPlaceholderText("Enter email");
// ⚠️ 备选方案
screen.getByText("Submit");
// ❌ 最后备选
screen.getByTestId("submit-button");Common Issues
常见问题
Issue 1: Act Warnings
问题1:Act警告
Symptoms: "Warning: An update to Component was not wrapped in act()"
Cause: State updates not properly awaited
Solution: Use or queries
waitForfindBytypescript
// ❌ Causes act warning
render(<AsyncComponent />);
expect(screen.getByText("Loaded")).toBeInTheDocument();
// ✅ Waits for update
render(<AsyncComponent />);
await screen.findByText("Loaded");症状:"Warning: An update to Component was not wrapped in act()"
原因:状态更新未正确等待
解决方案:使用或者类查询
waitForfindBytypescript
// ❌ 会触发act警告
render(<AsyncComponent />);
expect(screen.getByText("Loaded")).toBeInTheDocument();
// ✅ 等待更新完成
render(<AsyncComponent />);
await screen.findByText("Loaded");Issue 2: Can't Find Element
问题2:找不到元素
Symptoms: "Unable to find an element"
Cause: Wrong query or timing issue
Solution: Use correct query method and wait for element
typescript
// ❌ Element not visible yet
expect(screen.getByText("Success")).toBeInTheDocument();
// ✅ Wait for element to appear
await screen.findByText("Success");
// Or check if it doesn't exist
expect(screen.queryByText("Error")).not.toBeInTheDocument();症状:"Unable to find an element"
原因:查询方法错误或者时序问题
解决方案:使用正确的查询方法,等待元素渲染完成
typescript
// ❌ 元素还未渲染可见
expect(screen.getByText("Success")).toBeInTheDocument();
// ✅ 等待元素出现
await screen.findByText("Success");
// 或者检查元素不存在
expect(screen.queryByText("Error")).not.toBeInTheDocument();References
参考资料
- Vitest Documentation
- React Testing Library
- Testing Library Queries
- React Router Testing
- loader-action-optimizer skill
- Vitest Documentation
- React Testing Library
- Testing Library Queries
- React Router Testing
- loader-action-optimizer skill