Files
cs-note/hhs/REACT/5. 工程实践篇/16-测试.md
T
2026-05-24 11:42:38 +08:00

408 lines
12 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
---
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