This repository has been archived on 2026-05-24. You can view files and clone it. You cannot open issues or pull requests or push a commit.
Files
all-in-kingsoft/hhs/REACT/5. 工程实践篇/16-测试.md
T
2026-04-29 22:19:51 +08:00

226 lines
6.1 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();
});
});
```
## Mock 异步操作
```tsx
it("loading 态在请求完成后消失", async () => {
vi.mocked(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.mocked(fetch).mockRejectedValueOnce(new Error("Network error"));
render(<TodoList />);
await waitFor(() => {
expect(screen.getByText("Failed to load")).toBeInTheDocument();
});
});
```
## 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", delay: 300 },
});
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"); // 防抖完成
});
});
```
## E2E 测试选择
| 工具 | 适用场景 | 特点 |
|------|----------|------|
| **Playwright** | 全功能 E2E(推荐) | 跨浏览器、内置 trace、重试机制 |
| **Cypress** | 可视化调试友好 | DevTools 体验好、社区活跃 |
| **Puppeteer** | Google 官方、精细控制 | 底层 API、灵活性高 |
> [!note] E2E 边界
> - E2E 只应覆盖**关键用户旅程**(登录、下单、支付)
> - 不要为每个页面的每个字段写 E2E——那属于集成测试的范畴
## 关联笔记