408 lines
12 KiB
Markdown
408 lines
12 KiB
Markdown
|
|
---
|
|||
|
|
tags: [React, Testing, Vitest, RTL, Frontend]
|
|||
|
|
create time: 2026-04-29 22:15
|
|||
|
|
---
|
|||
|
|
|
|||
|
|
# 测试
|
|||
|
|
|
|||
|
|
## 概述
|
|||
|
|
|
|||
|
|
可靠的测试是大型 React 项目长期维护的基石。本文档以 Vitest + React Testing Library (RTL) 为主,介绍组件单元测试、Hook 测试和集成测试的最佳实践。
|
|||
|
|
|
|||
|
|
## 测试金字塔
|
|||
|
|
|
|||
|
|
```mermaid
|
|||
|
|
graph TB
|
|||
|
|
A["测试金字塔"]
|
|||
|
|
|
|||
|
|
A --> B["单元测试 ~70%"]
|
|||
|
|
A --> C["集成测试 ~20%"]
|
|||
|
|
A --> D["E2E 测试 ~10%"]
|
|||
|
|
|
|||
|
|
B --> B1["纯函数 / util"]
|
|||
|
|
B --> B2["自定义 Hook"]
|
|||
|
|
B --> B3["原子组件(Button)"]
|
|||
|
|
|
|||
|
|
C --> C1["多组件交互流程"]
|
|||
|
|
C --> C2["表单提交 → API → 状态更新"]
|
|||
|
|
|
|||
|
|
D --> D1["用户旅程:登录→搜索→下单"]
|
|||
|
|
|
|||
|
|
style B fill:#4FC08D,color:#fff
|
|||
|
|
style C fill:#F5A87D,color:#000
|
|||
|
|
style D fill:#61DAFB,color:#000
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
## 环境配置
|
|||
|
|
|
|||
|
|
```jsonc
|
|||
|
|
// vitest.config.ts
|
|||
|
|
import { defineConfig } from "vitest/config";
|
|||
|
|
import react from "@vitejs/plugin-react";
|
|||
|
|
|
|||
|
|
export default defineConfig({
|
|||
|
|
plugins: [react()],
|
|||
|
|
test: {
|
|||
|
|
environment: "jsdom", // 模拟浏览器 DOM
|
|||
|
|
setupFiles: "./src/test/setup.ts",
|
|||
|
|
globals: true,
|
|||
|
|
},
|
|||
|
|
});
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
```ts
|
|||
|
|
// src/test/setup.ts
|
|||
|
|
import "@testing-library/jest-dom/vitest"; // 扩展 expect 匹配器
|
|||
|
|
import { vi } from "vitest";
|
|||
|
|
|
|||
|
|
// Mock window.matchMedia(解决媒体查询测试报错)
|
|||
|
|
Object.defineProperty(window, "matchMedia", {
|
|||
|
|
writable: true,
|
|||
|
|
value: vi.fn().mockImplementation(query => ({
|
|||
|
|
matches: false,
|
|||
|
|
media: query,
|
|||
|
|
onchange: null,
|
|||
|
|
addListener: vi.fn(), // deprecated
|
|||
|
|
removeListener: vi.fn(), // deprecated
|
|||
|
|
addEventListener: vi.fn(),
|
|||
|
|
removeEventListener: vi.fn(),
|
|||
|
|
dispatchEvent: vi.fn(),
|
|||
|
|
})),
|
|||
|
|
});
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
## RTL 核心哲学
|
|||
|
|
|
|||
|
|
> [!tip] RTL 设计原则
|
|||
|
|
> - **测试行为,不测试实现** — 关注用户能感知到的东西(文本、按钮、网络请求)
|
|||
|
|
> - **像用户一样思考** — 用 `screen.getByRole("button", { name: "Submit" })` 而非 `.querySelector(".btn-primary"`
|
|||
|
|
> - **断言明确期望的结果** — 不要测试 state 的值,测试渲染输出
|
|||
|
|
|
|||
|
|
```tsx
|
|||
|
|
// ❌ 反例:耦合于内部实现
|
|||
|
|
expect(component.state.count).toBe(2);
|
|||
|
|
expect(wrapper.find(Button).length).toBe(1);
|
|||
|
|
|
|||
|
|
// ✅ 正例:基于用户感知
|
|||
|
|
const button = screen.getByRole("button", { name: /add/i });
|
|||
|
|
userEvent.click(button);
|
|||
|
|
await screen.findByText(/added!/i);
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
## 组件单元测试
|
|||
|
|
|
|||
|
|
### 基础模式
|
|||
|
|
|
|||
|
|
```tsx
|
|||
|
|
import { render, screen, fireEvent, waitFor } from "@testing-library/react";
|
|||
|
|
import userEvent from "@testing-library/user-event";
|
|||
|
|
import { Counter } from "./Counter";
|
|||
|
|
|
|||
|
|
describe("<Counter />", () => {
|
|||
|
|
it("初始显示 0", () => {
|
|||
|
|
render(<Counter initial={0} />);
|
|||
|
|
expect(screen.getByText("0")).toBeInTheDocument();
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
it("点击按钮后计数增加", async () => {
|
|||
|
|
render(<Counter initial={0} />);
|
|||
|
|
const button = screen.getByRole("button");
|
|||
|
|
|
|||
|
|
await userEvent.click(button);
|
|||
|
|
expect(screen.getByText("1")).toBeInTheDocument();
|
|||
|
|
|
|||
|
|
await userEvent.click(button);
|
|||
|
|
await userEvent.click(button);
|
|||
|
|
expect(screen.getByText("3")).toBeInTheDocument();
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
it("禁用态不可点击", () => {
|
|||
|
|
render(<Counter disabled initial={0} />);
|
|||
|
|
const button = screen.getByRole("button");
|
|||
|
|
expect(button).toBeDisabled();
|
|||
|
|
});
|
|||
|
|
});
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
### Props 驱动 UI
|
|||
|
|
|
|||
|
|
```tsx
|
|||
|
|
describe("<UserCard />", () => {
|
|||
|
|
it("显示用户基本信息", () => {
|
|||
|
|
render(<UserCard user={{ name: "Alice", role: "admin" }} />);
|
|||
|
|
expect(screen.getByText("Alice")).toBeInTheDocument();
|
|||
|
|
expect(screen.getByRole("img", { name: /avatar/i })).toHaveAttribute("alt", "Alice avatar");
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
it("显示操作菜单当 admin 时", () => {
|
|||
|
|
render(<UserCard user={{ name: "Alice", role: "admin" }} />);
|
|||
|
|
expect(screen.getByRole("button", { name: /edit/i })).toBeInTheDocument();
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
it("普通用户不显示操作菜单", () => {
|
|||
|
|
render(<UserCard user={{ name: "Bob", role: "user" }} />);
|
|||
|
|
expect(screen.queryByRole("button", { name: /edit/i })).not.toBeInTheDocument();
|
|||
|
|
});
|
|||
|
|
});
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
### 快照测试
|
|||
|
|
|
|||
|
|
> [!tip] Snapshot 的定位
|
|||
|
|
> 快照不是单元测试的替代品——它检测的是**UI 结构意外变化**。
|
|||
|
|
> 适用于不会频繁变化的展示型组件(如仪表盘卡片、文章详情页)。
|
|||
|
|
> 不适用于动态数据多的列表或表单。
|
|||
|
|
|
|||
|
|
```tsx
|
|||
|
|
import { render } from "@testing-library/react";
|
|||
|
|
import { UserDetail } from "./UserDetail";
|
|||
|
|
|
|||
|
|
it("渲染与之前一致", () => {
|
|||
|
|
const { container } = render(<UserDetail user={mockUser} />);
|
|||
|
|
expect(container).toMatchSnapshot();
|
|||
|
|
});
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
> [!warning] 快照的陷阱
|
|||
|
|
> - 时间戳、随机 ID 等动态内容会导致每次快照不同 → 用 `sanitize` 处理
|
|||
|
|
> - 盲目更新快照(`-u`)等于放弃测试价值 → **每次 diff 都要人工审查**
|
|||
|
|
> - 配合确定性断言一起使用,不要只做快照断言
|
|||
|
|
|
|||
|
|
### 查询方法速查表
|
|||
|
|
|
|||
|
|
> [!question] 思考:getBy、queryBy、findBy 我该用哪个?
|
|||
|
|
> React Testing Library 提供三类查询,它们的**行为差异直接决定测试的健壮性**。
|
|||
|
|
|
|||
|
|
| 前缀 | 匹配不到时 | 典型场景 |
|
|||
|
|
|------|-----------|---------|
|
|||
|
|
| `getBy*` | **抛异常**(测试失败) | 验证元素**必须存在** |
|
|||
|
|
| `queryBy*` | 返回 `null` | 验证元素**不应存在**(配合 `.not.toBeInTheDocument()`) |
|
|||
|
|
| `findBy*` | **超时后抛异常** | 等待**异步出现**的元素(内部自动 await) |
|
|||
|
|
|
|||
|
|
```tsx
|
|||
|
|
// getBy — 必须有,没有就失败(同步)
|
|||
|
|
const submitBtn = screen.getByRole("button", { name: /submit/i });
|
|||
|
|
|
|||
|
|
// queryBy — 验证"不存在"(同步,推荐优先于 findBy 做否定断言)
|
|||
|
|
expect(screen.queryByRole("alert")).not.toBeInTheDocument();
|
|||
|
|
|
|||
|
|
// findBy — 等待异步出现的元素(内部封装了 waitFor + polling)
|
|||
|
|
const successMsg = await screen.findByText("Saved!");
|
|||
|
|
|
|||
|
|
// ✅ 避免:手动写 waitFor + getBy(findBy 更简洁)
|
|||
|
|
// await waitFor(() => expect(screen.getByText("Saved!")).toBeInTheDocument());
|
|||
|
|
|
|||
|
|
// getAllBy — 匹配多个元素
|
|||
|
|
const checkboxes = screen.getAllByRole("checkbox");
|
|||
|
|
expect(checkboxes).toHaveLength(3);
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
> [!tip] 查询优先级原则
|
|||
|
|
> 按以下顺序选择查询方式,越靠前的优先级越高:
|
|||
|
|
>
|
|||
|
|
> `role` → `label` → `text` → `testID` → `screen`(最后手段)
|
|||
|
|
>
|
|||
|
|
> **永远不要**用 `container.querySelector(".css-class")`——样式变了测试就挂了。
|
|||
|
|
|
|||
|
|
---
|
|||
|
|
|
|||
|
|
## Mock 异步操作
|
|||
|
|
|
|||
|
|
### vi.mock() — 模块级 Mock
|
|||
|
|
|
|||
|
|
```tsx
|
|||
|
|
// 内联 Mock,隔离 API 依赖
|
|||
|
|
vi.mock("../api/user", () => ({
|
|||
|
|
getUser: vi.fn().mockResolvedValue({ id: 1, name: "Alice" }),
|
|||
|
|
updateUser: vi.fn().mockResolvedValue(undefined),
|
|||
|
|
}));
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
### Mock Fetch / Axios
|
|||
|
|
|
|||
|
|
```tsx
|
|||
|
|
it("loading 态在请求完成后消失", async () => {
|
|||
|
|
// 推荐:用 vi.spyOn 包装全局 fetch
|
|||
|
|
vi.spyOn(global, "fetch").mockResolvedValueOnce({
|
|||
|
|
ok: true,
|
|||
|
|
json: async () => [{ id: 1, name: "Test" }],
|
|||
|
|
} as Response);
|
|||
|
|
|
|||
|
|
render(<TodoList />);
|
|||
|
|
|
|||
|
|
// 等待 loading 态出现再消失
|
|||
|
|
const spinner = await screen.findByRole("status");
|
|||
|
|
expect(spinner).toHaveTextContent("Loading...");
|
|||
|
|
|
|||
|
|
// 数据渲染完成
|
|||
|
|
const item = await screen.findByText("Test");
|
|||
|
|
expect(item).toBeInTheDocument();
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
it("网络错误显示错误提示", async () => {
|
|||
|
|
vi.spyOn(global, "fetch").mockRejectedValueOnce(new Error("Network error"));
|
|||
|
|
|
|||
|
|
render(<TodoList />);
|
|||
|
|
|
|||
|
|
await waitFor(() => {
|
|||
|
|
expect(screen.getByText("Failed to load")).toBeInTheDocument();
|
|||
|
|
});
|
|||
|
|
});
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
> [!note] API Mock 方案选型
|
|||
|
|
> - **vi.mock() / vi.spyOn**:适合单元测试,快速隔离依赖,但无法模拟完整网络流程
|
|||
|
|
> - **MSW (Mock Service Worker)**:通过 Service Worker 拦截真实网络请求,适用于集成测试;与生产行为最接近
|
|||
|
|
> - **建议**:组件层用 vi.mock;集成流用 MSW
|
|||
|
|
|
|||
|
|
---
|
|||
|
|
|
|||
|
|
## Hook 测试
|
|||
|
|
|
|||
|
|
```tsx
|
|||
|
|
import { renderHook, act } from "@testing-library/react";
|
|||
|
|
import { useDebounce } from "../hooks/useDebounce";
|
|||
|
|
|
|||
|
|
describe("useDebounce", () => {
|
|||
|
|
beforeEach(() => vi.useFakeTimers());
|
|||
|
|
afterEach(() => vi.useRealTimers());
|
|||
|
|
|
|||
|
|
it("值不变时返回原始值", () => {
|
|||
|
|
const { result } = renderHook(
|
|||
|
|
({ value }) => useDebounce(value, 300),
|
|||
|
|
{ initialProps: { value: "hello" } }
|
|||
|
|
);
|
|||
|
|
|
|||
|
|
expect(result.current).toBe("hello");
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
it("延迟后返回新值", async () => {
|
|||
|
|
const { result, rerender } = renderHook(
|
|||
|
|
({ value }) => useDebounce(value, 300),
|
|||
|
|
{ initialProps: { value: "a" } }
|
|||
|
|
);
|
|||
|
|
|
|||
|
|
rerender({ value: "b" });
|
|||
|
|
expect(result.current).toBe("a"); // 尚未变化
|
|||
|
|
|
|||
|
|
act(() => vi.advanceTimersByTime(300));
|
|||
|
|
expect(result.current).toBe("b"); // 防抖完成
|
|||
|
|
});
|
|||
|
|
});
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
> [!tip] Hook 测试的关键模式
|
|||
|
|
> - **时间控制**:用 `vi.useFakeTimers()` + `act(() => vi.advanceTimersByTime(n))` 精确控制异步时序,不依赖真实等待
|
|||
|
|
> - **cleanup**:每个 `beforeEach` 对应独立的渲染上下文,`rerender` 模拟 props 更新时的行为
|
|||
|
|
> - **不要断言内部变量**:只检查 `result.current`(返回值),就像组件只暴露 props 一样
|
|||
|
|
|
|||
|
|
---
|
|||
|
|
|
|||
|
|
## 集成测试模式
|
|||
|
|
|
|||
|
|
```tsx
|
|||
|
|
// 模拟一个完整的用户操作流程
|
|||
|
|
describe("<SignupForm /> — 集成测试", () => {
|
|||
|
|
it("完整注册流程:填写 → 提交 → 跳转", async () => {
|
|||
|
|
// 1. Mock API 响应
|
|||
|
|
vi.mock("../api/auth", () => ({
|
|||
|
|
register: vi.fn().mockResolvedValue({ token: "abc123" }),
|
|||
|
|
}));
|
|||
|
|
|
|||
|
|
// 2. 渲染表单
|
|||
|
|
render(<SignupForm />);
|
|||
|
|
|
|||
|
|
// 3. 模拟用户输入(userEvent 更符合真实行为)
|
|||
|
|
const emailInput = screen.getByLabelText(/email/i);
|
|||
|
|
const passwordInput = screen.getByLabelText(/password/i);
|
|||
|
|
|
|||
|
|
await userEvent.type(emailInput, "alice@example.com");
|
|||
|
|
await userEvent.type(passwordInput, "SecurePass123!");
|
|||
|
|
|
|||
|
|
// 4. 提交
|
|||
|
|
const submitBtn = screen.getByRole("button", { name: /sign up/i });
|
|||
|
|
await userEvent.click(submitBtn);
|
|||
|
|
|
|||
|
|
// 5. 验证结果
|
|||
|
|
await screen.findByText("Account created!");
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
it("重复邮箱提示错误", async () => {
|
|||
|
|
vi.mock("../api/auth", () => ({
|
|||
|
|
register: vi.fn().mockRejectedValue(new Error("Email already exists")),
|
|||
|
|
}));
|
|||
|
|
|
|||
|
|
render(<SignupForm />);
|
|||
|
|
|
|||
|
|
const emailInput = screen.getByLabelText(/email/i);
|
|||
|
|
await userEvent.type(emailInput, "exists@example.com");
|
|||
|
|
await userEvent.click(screen.getByRole("button", { name: /sign up/i }));
|
|||
|
|
|
|||
|
|
await screen.findByText("Email already exists");
|
|||
|
|
});
|
|||
|
|
});
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
> [!important] 集成测试的设计要点
|
|||
|
|
> - **一次测一个完整流程**,不要拆成原子步骤——这样即使中间环节变动,只要最终行为一致就不需要改测试
|
|||
|
|
> - **Mock 所有外部依赖**(API、localStorage、WebSocket),但不 mock 内部逻辑
|
|||
|
|
> 集成测试的目标是:**确认多个组件协同工作后,用户得到了正确的反馈**
|
|||
|
|
|
|||
|
|
---
|
|||
|
|
|
|||
|
|
## 错误边界测试
|
|||
|
|
|
|||
|
|
```tsx
|
|||
|
|
import { ErrorBoundary } from "./ErrorBoundary";
|
|||
|
|
|
|||
|
|
describe("<ErrorBoundary />", () => {
|
|||
|
|
// 让被包裹的组件抛出错误
|
|||
|
|
function BrokenComponent() {
|
|||
|
|
throw new Error("Render failed");
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
it("捕获渲染错误并显示 fallback UI", () => {
|
|||
|
|
render(
|
|||
|
|
<ErrorBoundary fallback={<div>Something went wrong</div>}>
|
|||
|
|
<BrokenComponent />
|
|||
|
|
</ErrorBoundary>
|
|||
|
|
);
|
|||
|
|
|
|||
|
|
expect(screen.getByText("Something went wrong")).toBeInTheDocument();
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
it("未发生错误时正常渲染子内容", () => {
|
|||
|
|
render(
|
|||
|
|
<ErrorBoundary>
|
|||
|
|
<span>Safe content</span>
|
|||
|
|
</ErrorBoundary>
|
|||
|
|
);
|
|||
|
|
|
|||
|
|
expect(screen.getByText("Safe content")).toBeInTheDocument();
|
|||
|
|
});
|
|||
|
|
});
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
---
|
|||
|
|
|
|||
|
|
## E2E 测试选择
|
|||
|
|
|
|||
|
|
| 工具 | 适用场景 | 特点 |
|
|||
|
|
|------|----------|------|
|
|||
|
|
| **Playwright** | 全功能 E2E(推荐) | 跨浏览器、内置 trace、重试机制 |
|
|||
|
|
| **Cypress** | 可视化调试友好 | DevTools 体验好、社区活跃 |
|
|||
|
|
| **Puppeteer** | Google 官方、精细控制 | 底层 API、灵活性高 |
|
|||
|
|
|
|||
|
|
> [!note] E2E 边界
|
|||
|
|
> - E2E 只应覆盖**关键用户旅程**(登录、下单、支付)
|
|||
|
|
> - 不要为每个页面的每个字段写 E2E——那属于集成测试的范畴
|
|||
|
|
|
|||
|
|
---
|
|||
|
|
|
|||
|
|
## 关联笔记
|
|||
|
|
|
|||
|
|
- [[07-自定义 Hooks]] — 自定义 Hook 的设计与测试模式
|
|||
|
|
- [[09-状态管理]] — 全局状态管理(Zustand / Redux Toolkit)的测试策略
|
|||
|
|
- [[13-并发特性]] — Suspense、Transitions 的测试注意事项
|
|||
|
|
- [[15-性能优化]] — 性能回归测试:React Testing Library + Performance Assertions
|