--- 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("", () => { it("初始显示 0", () => { render(); expect(screen.getByText("0")).toBeInTheDocument(); }); it("点击按钮后计数增加", async () => { render(); 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(); const button = screen.getByRole("button"); expect(button).toBeDisabled(); }); }); ``` ### Props 驱动 UI ```tsx describe("", () => { it("显示用户基本信息", () => { render(); expect(screen.getByText("Alice")).toBeInTheDocument(); expect(screen.getByRole("img", { name: /avatar/i })).toHaveAttribute("alt", "Alice avatar"); }); it("显示操作菜单当 admin 时", () => { render(); expect(screen.getByRole("button", { name: /edit/i })).toBeInTheDocument(); }); it("普通用户不显示操作菜单", () => { render(); 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(); // 等待 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(); 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——那属于集成测试的范畴 ## 关联笔记