Files
2026-05-24 11:42:38 +08:00

12 KiB
Raw Permalink Blame History

tags, create time
tags create time
React
Testing
Vitest
RTL
Frontend
2026-04-29 22:15

测试

概述

可靠的测试是大型 React 项目长期维护的基石。本文档以 Vitest + React Testing Library (RTL) 为主,介绍组件单元测试、Hook 测试和集成测试的最佳实践。

测试金字塔

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

环境配置

// 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,
  },
});
// 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 的值,测试渲染输出
// ❌ 反例:耦合于内部实现
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);

组件单元测试

基础模式

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

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 结构意外变化。 适用于不会频繁变化的展示型组件(如仪表盘卡片、文章详情页)。 不适用于动态数据多的列表或表单。

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)
// 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

// 内联 Mock,隔离 API 依赖
vi.mock("../api/user", () => ({
  getUser: vi.fn().mockResolvedValue({ id: 1, name: "Alice" }),
  updateUser: vi.fn().mockResolvedValue(undefined),
}));

Mock Fetch / Axios

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 测试

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 一样

集成测试模式

// 模拟一个完整的用户操作流程
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 内部逻辑 集成测试的目标是:确认多个组件协同工作后,用户得到了正确的反馈

错误边界测试

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