vault backup: 2026-04-29 22:19:51
This commit is contained in:
@@ -0,0 +1,225 @@
|
||||
---
|
||||
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——那属于集成测试的范畴
|
||||
|
||||
## 关联笔记
|
||||
Reference in New Issue
Block a user