跳至内容
·16 分钟阅读

Vitest 测试指南:从零到生产级信心

单元测试、集成测试、Testing Library 组件测试、Mock 策略、覆盖率配置,以及真正能提升软件质量的测试哲学。

分享:X / TwitterLinkedIn

大多数团队写测试的方式和大多数人锻炼的方式差不多:他们知道应该做,不做的时候有负罪感,等到终于开始做了,第一天就用力过猛,到周五就放弃了。代码库最终只剩下一堆浅层快照测试(每次改个 CSS 类就会挂),几个没人信任的集成测试,以及 README 里一个不诚实的覆盖率徽章。

我两种情况都经历过。我发布过零测试的项目,每次部署都提心吊胆。我也待过追求 100% 覆盖率的团队,花在维护测试上的时间比写功能还多。两种方式都不行。真正有效的是在正确的地方、用正确的工具写正确的测试。

Vitest 改变了我对 JavaScript 测试的看法。不是因为它发明了新概念——基本原理从 Kent Beck 几十年前写下来之后就没变过。而是因为它消除了足够多的摩擦,让写测试不再像苦差事,而是成为开发循环的一部分。当你的测试运行器和开发服务器一样快、用同样的配置时,借口就不存在了。

这篇文章是我关于 Vitest 测试的全部知识,从初始设置到让一切都值得的测试哲学。

为什么选择 Vitest 而不是 Jest#

如果你用过 Jest,你已经了解 Vitest 的大部分 API。这是设计如此——Vitest 在 API 层面兼容 Jest。describeitexpectbeforeEachvi.fn() ——都能用。那为什么要切换?

原生 ESM 支持#

Jest 是为 CommonJS 构建的。它能处理 ESM,但需要配置、实验性标志,偶尔还要祈祷。如果你在用 import/export 语法(也就是所有现代代码),你大概和 Jest 的 transform 管道打过架。

Vitest 运行在 Vite 上。Vite 原生理解 ESM。你的源代码不需要 transform 步骤——直接就能跑。这比听起来更重要。我这些年调试的 Jest 问题,有一半追溯到模块解析:SyntaxError: Cannot use import statement outside a module,或者 mock 不工作因为模块已经被缓存为不同格式。

和开发服务器共用配置#

如果你的项目用 Vite(在 2026 年,如果你在构建 React、Vue 或 Svelte 应用,大概率是用的),Vitest 会自动读取你的 vite.config.ts。你的路径别名、插件和环境变量在测试中直接可用,不需要额外配置。用 Jest 的话,你需要维护一个并行的配置,它必须和你的打包器设置保持同步。每次在 vite.config.ts 中添加路径别名,你都得记得在 jest.config.ts 中添加对应的 moduleNameMapper。这是个小事,但小事会累积。

速度#

Vitest 是快的。真正意义上的快。不是"节省两秒"的快——而是"改变你工作方式"的快。它利用 Vite 的模块图来理解哪些测试受文件变更影响,只运行那些测试。它的 watch 模式使用了和 Vite 开发服务器一样的 HMR 基础设施。

在一个有 400+ 测试的项目上,从 Jest 切换到 Vitest 把我们的 watch 模式反馈循环从约 4 秒缩短到 500ms 以下。这是"我等测试跑完"和"手指还在键盘上就瞄一眼终端"的区别。

内置基准测试#

Vitest 内置了 bench() 用于性能测试。不需要单独的库:

typescript
import { bench, describe } from "vitest";
 
describe("string concatenation", () => {
  bench("template literals", () => {
    const name = "world";
    const _result = `hello ${name}`;
  });
 
  bench("string concat", () => {
    const name = "world";
    const _result = "hello " + name;
  });
});

vitest bench 运行。它不是主角,但在同一个工具链中有性能测试而不用安装 benchmark.js 和配置单独的运行器,挺好的。

设置#

安装#

bash
npm install -D vitest @testing-library/react @testing-library/jest-dom @testing-library/user-event jsdom

配置#

在项目根目录创建 vitest.config.ts

typescript
import { defineConfig } from "vitest/config";
import react from "@vitejs/plugin-react";
import path from "path";
 
export default defineConfig({
  plugins: [react()],
  test: {
    globals: true,
    environment: "jsdom",
    setupFiles: ["./src/test/setup.ts"],
    include: ["src/**/*.{test,spec}.{ts,tsx}"],
    alias: {
      "@": path.resolve(__dirname, "./src"),
    },
    css: false,
  },
});

或者,如果你已经有 vite.config.ts,可以扩展它:

typescript
/// <reference types="vitest/config" />
import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";
 
export default defineConfig({
  plugins: [react()],
  resolve: {
    alias: {
      "@": "/src",
    },
  },
  test: {
    globals: true,
    environment: "jsdom",
    setupFiles: ["./src/test/setup.ts"],
  },
});

关于 globals: true 的选择#

globalstrue 时,你不需要导入 describeitexpectbeforeEach 等——它们到处可用,就像 Jest 一样。设为 false 时,你需要显式导入:

typescript
// globals: false
import { describe, it, expect } from "vitest";
 
describe("math", () => {
  it("adds numbers", () => {
    expect(1 + 1).toBe(2);
  });
});

我用 globals: true 因为它减少视觉噪音并且符合大多数开发者的期望。如果你的团队重视显式导入,设为 false——这没有错误答案。

如果你用 globals: true,在 tsconfig.json 中添加 Vitest 的类型让 TypeScript 识别它们:

json
{
  "compilerOptions": {
    "types": ["vitest/globals"]
  }
}

环境:jsdom vs happy-dom vs node#

Vitest 让你可以按测试或全局选择 DOM 实现:

  • node — 没有 DOM。用于纯逻辑、工具函数、API 路由以及任何不涉及浏览器的东西。
  • jsdom — 标准选择。完整的 DOM 实现。更重但更完整。
  • happy-dom — 比 jsdom 更轻更快但不够完整。一些边界情况(如 RangeSelectionIntersectionObserver)可能不工作。

我默认全局用 jsdom,需要 node 时按文件覆盖:

typescript
// src/lib/utils.test.ts
// @vitest-environment node
 
import { formatDate, slugify } from "./utils";
 
describe("slugify", () => {
  it("converts spaces to hyphens", () => {
    expect(slugify("hello world")).toBe("hello-world");
  });
});

Setup 文件#

Setup 文件在每个测试文件之前运行。在这里配置 Testing Library matcher 和全局 mock:

typescript
// src/test/setup.ts
import "@testing-library/jest-dom/vitest";
 
// Mock IntersectionObserver — jsdom doesn't implement it
class MockIntersectionObserver {
  observe = vi.fn();
  unobserve = vi.fn();
  disconnect = vi.fn();
}
 
Object.defineProperty(window, "IntersectionObserver", {
  writable: true,
  value: MockIntersectionObserver,
});
 
// Mock window.matchMedia — needed for responsive components
Object.defineProperty(window, "matchMedia", {
  writable: true,
  value: vi.fn().mockImplementation((query: string) => ({
    matches: false,
    media: query,
    onchange: null,
    addListener: vi.fn(),
    removeListener: vi.fn(),
    addEventListener: vi.fn(),
    removeEventListener: vi.fn(),
    dispatchEvent: vi.fn(),
  })),
});

@testing-library/jest-dom/vitest 导入给你提供了 toBeInTheDocument()toHaveClass()toBeVisible() 等 matcher。这些让 DOM 元素上的断言可读且富有表现力。

编写好的测试#

AAA 模式#

每个测试都遵循相同的结构:Arrange(准备)、Act(执行)、Assert(断言)。即使你不写明确的注释,结构也应该是可见的:

typescript
it("calculates total with tax", () => {
  // Arrange
  const items = [
    { name: "Widget", price: 10 },
    { name: "Gadget", price: 20 },
  ];
  const taxRate = 0.08;
 
  // Act
  const total = calculateTotal(items, taxRate);
 
  // Assert
  expect(total).toBe(32.4);
});

当我看到一个把准备、执行和断言混在一串方法调用链中的测试时,我就知道它失败时会很难理解。即使不加注释,也要保持三个阶段在视觉上清晰分开。

测试命名#

有两种风格:it('should calculate total with tax')it('calculates total with tax')。"should" 前缀是冗余的,没有添加信息。当测试失败时,你会看到:

FAIL  ✕ calculates total with tax

这已经是一个完整的句子了。加 "should" 只是增加噪音。我更喜欢直接形式:it('renders loading state')it('rejects invalid email')it('returns empty array when no matches found')

对于 describe 块,使用被测单元的名称:

typescript
describe("calculateTotal", () => {
  it("sums item prices", () => { /* ... */ });
  it("applies tax rate", () => { /* ... */ });
  it("returns 0 for empty array", () => { /* ... */ });
  it("handles negative prices", () => { /* ... */ });
});

大声读出来:"calculateTotal sums item prices。" "calculateTotal applies tax rate。" 如果句子读得通,命名就没问题。

每个测试一个断言 vs 实际分组#

纯粹主义者的规则说每个测试一个断言。实际的规则说:每个测试一个 概念。这是不同的。

typescript
// This is fine — one concept, multiple assertions
it("formats user display name", () => {
  const user = { firstName: "John", lastName: "Doe", title: "Dr." };
  const result = formatDisplayName(user);
 
  expect(result).toContain("John");
  expect(result).toContain("Doe");
  expect(result).toStartWith("Dr.");
});
 
// This is not fine — multiple concepts in one test
it("handles user operations", () => {
  const user = createUser("John");
  expect(user.id).toBeDefined();
 
  const updated = updateUser(user.id, { name: "Jane" });
  expect(updated.name).toBe("Jane");
 
  deleteUser(user.id);
  expect(getUser(user.id)).toBeNull();
});

第一个测试有三个断言但它们都验证一个东西:显示名称的格式。如果任何断言失败,你确切知道什么坏了。第二个测试是三个独立的测试硬塞在一起。如果第二个断言失败,你不知道是创建还是更新出了问题,而且第三个断言永远不会运行。

测试描述即文档#

好的测试套件就是活文档。不熟悉代码的人应该能通过阅读测试描述来理解功能的行为:

typescript
describe("PasswordValidator", () => {
  describe("minimum length", () => {
    it("rejects passwords shorter than 8 characters", () => { /* ... */ });
    it("accepts passwords with exactly 8 characters", () => { /* ... */ });
  });
 
  describe("character requirements", () => {
    it("requires at least one uppercase letter", () => { /* ... */ });
    it("requires at least one number", () => { /* ... */ });
    it("requires at least one special character", () => { /* ... */ });
  });
 
  describe("common password check", () => {
    it("rejects passwords in the common passwords list", () => { /* ... */ });
    it("performs case-insensitive comparison", () => { /* ... */ });
  });
});

当这个测试套件运行时,输出读起来像规格说明。这就是目标。

Mock#

Mock 是测试工具箱中最强大也最危险的工具。用得好,它隔离被测单元,让测试快速且确定。用得不好,它创建出无论代码做什么都会通过的测试。

vi.fn() — 创建 Mock 函数#

最简单的 mock 是一个记录调用的函数:

typescript
const mockCallback = vi.fn();
 
// Call it
mockCallback("hello", 42);
mockCallback("world");
 
// Assert on calls
expect(mockCallback).toHaveBeenCalledTimes(2);
expect(mockCallback).toHaveBeenCalledWith("hello", 42);
expect(mockCallback).toHaveBeenLastCalledWith("world");

你可以给它一个返回值:

typescript
const mockFetch = vi.fn().mockResolvedValue({
  ok: true,
  json: () => Promise.resolve({ id: 1, name: "Test" }),
});

或者让它在连续调用时返回不同的值:

typescript
const mockRandom = vi.fn()
  .mockReturnValueOnce(0.1)
  .mockReturnValueOnce(0.5)
  .mockReturnValueOnce(0.9);

vi.spyOn() — 监视真实方法#

当你想观察一个方法但不替换其行为时:

typescript
const consoleSpy = vi.spyOn(console, "warn");
 
validateInput("");
 
expect(consoleSpy).toHaveBeenCalledWith("Input cannot be empty");
consoleSpy.mockRestore();

spyOn 默认保留原始实现。需要时可以用 .mockImplementation() 覆盖,之后用 .mockRestore() 恢复原始实现。

vi.mock() — 模块级别 Mock#

这是重头戏。vi.mock() 替换整个模块:

typescript
// Mock the entire module
vi.mock("@/lib/api", () => ({
  fetchUsers: vi.fn().mockResolvedValue([
    { id: 1, name: "Alice" },
    { id: 2, name: "Bob" },
  ]),
  fetchUser: vi.fn().mockResolvedValue({ id: 1, name: "Alice" }),
}));
 
// The import now uses the mocked version
import { fetchUsers } from "@/lib/api";
 
describe("UserList", () => {
  it("displays users from API", async () => {
    const users = await fetchUsers();
    expect(users).toHaveLength(2);
  });
});

Vitest 会自动将 vi.mock() 调用提升到文件顶部。这意味着 mock 在任何导入运行之前就位。你不需要担心导入顺序。

自动 Mock#

如果你只想把每个导出替换为 vi.fn()

typescript
vi.mock("@/lib/analytics");
 
import { trackEvent, trackPageView } from "@/lib/analytics";
 
it("tracks form submission", () => {
  submitForm();
  expect(trackEvent).toHaveBeenCalledWith("form_submit", expect.any(Object));
});

没有工厂函数时,Vitest 自动 mock 所有导出。每个导出的函数变成返回 undefinedvi.fn()。这对你想要静默的模块(如分析或日志)很有用,不需要指定每个函数。

Clear vs Reset vs Restore#

这一点总是让人困惑:

typescript
const mockFn = vi.fn().mockReturnValue(42);
mockFn();
 
// mockClear — resets call history, keeps implementation
mockFn.mockClear();
expect(mockFn).not.toHaveBeenCalled(); // true
expect(mockFn()).toBe(42); // still returns 42
 
// mockReset — resets call history AND implementation
mockFn.mockReset();
expect(mockFn()).toBeUndefined(); // no longer returns 42
 
// mockRestore — for spies, restores original implementation
const spy = vi.spyOn(Math, "random").mockReturnValue(0.5);
spy.mockRestore();
// Math.random() now works normally again

实践中,在 beforeEach 中用 vi.clearAllMocks() 在测试之间重置调用历史。如果你用了 spyOn 并想恢复原始实现,用 vi.restoreAllMocks()

typescript
beforeEach(() => {
  vi.clearAllMocks();
});
 
afterEach(() => {
  vi.restoreAllMocks();
});

过度 Mock 的危险#

这是我能给出的最重要的 mock 建议:每个 mock 都是你对测试撒的谎。 当你 mock 一个依赖时,你在说"我相信这个东西能正确工作,所以我用一个简化版本替代它。"如果你的假设是错的,测试通过但功能是坏的。

typescript
// Over-mocked — tests nothing useful
vi.mock("@/lib/validator");
vi.mock("@/lib/formatter");
vi.mock("@/lib/api");
vi.mock("@/lib/cache");
 
it("processes user input", () => {
  processInput("hello");
  expect(validator.validate).toHaveBeenCalledWith("hello");
  expect(formatter.format).toHaveBeenCalledWith("hello");
});

这个测试验证了 processInput 调用了 validateformat。但如果 processInput 以错误的顺序调用它们呢?如果它忽略了它们的返回值呢?如果校验应该阻止格式化步骤运行呢?测试不知道。你已经 mock 掉了所有有趣的行为。

经验法则:在边界处 mock,不要在中间 mock。 Mock 网络请求、文件系统访问和第三方服务。不要 mock 你自己的工具函数,除非有充分的理由(比如它们运行代价高或有副作用)。

测试 React 组件#

Testing Library 基础#

Testing Library 强制执行一个理念:用用户交互的方式测试组件。不检查内部状态,不检查组件实例,不做浅渲染。你渲染一个组件并通过 DOM 与它交互,就像用户那样。

typescript
import { render, screen } from "@testing-library/react";
import { Button } from "@/components/ui/Button";
 
describe("Button", () => {
  it("renders with label text", () => {
    render(<Button>Click me</Button>);
    expect(screen.getByRole("button", { name: "Click me" })).toBeInTheDocument();
  });
 
  it("applies variant classes", () => {
    render(<Button variant="primary">Submit</Button>);
    const button = screen.getByRole("button");
    expect(button).toHaveClass("bg-primary");
  });
 
  it("is disabled when disabled prop is true", () => {
    render(<Button disabled>Submit</Button>);
    expect(screen.getByRole("button")).toBeDisabled();
  });
});

查询:getBy vs queryBy vs findBy#

这是初学者容易困惑的地方。有三种查询变体,每种都有特定的用例:

getBy* — 返回元素,如果找不到就抛出错误。在你期望元素存在时使用:

typescript
// Throws if no button found — test fails with a helpful error
const button = screen.getByRole("button", { name: "Submit" });

queryBy* — 返回元素或 null(如果找不到)。在你断言某东西不存在时使用:

typescript
// Returns null — doesn't throw
expect(screen.queryByText("Error message")).not.toBeInTheDocument();

findBy* — 返回 Promise。用于异步出现的元素:

typescript
// Waits up to 1000ms for the element to appear
const successMessage = await screen.findByText("Saved successfully");
expect(successMessage).toBeInTheDocument();

无障碍优先的查询#

Testing Library 按特定优先级顺序提供这些查询:

  1. getByRole — 最佳查询。使用 ARIA 角色。如果你的组件通过角色找不到,它可能有无障碍问题。
  2. getByLabelText — 用于表单元素。如果你的输入没有标签,先修复那个。
  3. getByPlaceholderText — 可接受但较弱。用户输入时占位符会消失。
  4. getByText — 用于非交互元素。通过可见文本内容查找。
  5. getByTestId — 最后手段。当没有语义查询可用时使用。
typescript
// Prefer this
screen.getByRole("textbox", { name: "Email address" });
 
// Over this
screen.getByPlaceholderText("Enter your email");
 
// And definitely over this
screen.getByTestId("email-input");

这个排序不是随意的。它匹配辅助技术浏览页面的方式。如果你能通过角色和无障碍名称找到元素,屏幕阅读器也能。如果你只能通过 test ID 找到它,你可能有无障碍缺口。

用户事件#

不要用 fireEvent,用 @testing-library/user-event。区别很重要:

typescript
import userEvent from "@testing-library/user-event";
 
describe("SearchInput", () => {
  it("filters results as user types", async () => {
    const user = userEvent.setup();
    const onSearch = vi.fn();
 
    render(<SearchInput onSearch={onSearch} />);
 
    const input = screen.getByRole("searchbox");
    await user.type(input, "vitest");
 
    // user.type fires keydown, keypress, input, keyup for EACH character
    // fireEvent.change just sets the value — skipping realistic event flow
    expect(onSearch).toHaveBeenLastCalledWith("vitest");
  });
 
  it("clears input on escape key", async () => {
    const user = userEvent.setup();
 
    render(<SearchInput onSearch={vi.fn()} />);
 
    const input = screen.getByRole("searchbox");
    await user.type(input, "hello");
    await user.keyboard("{Escape}");
 
    expect(input).toHaveValue("");
  });
});

userEvent 模拟真实浏览器会触发的完整事件链。fireEvent.change 只是一个单独的合成事件。如果你的组件监听 onKeyDown 或使用 onInput 而不是 onChangefireEvent.change 不会触发那些处理器,但 userEvent.type 会。

始终在开头调用 userEvent.setup() 并使用返回的 user 实例。这确保了正确的事件顺序和状态追踪。

测试组件交互#

一个真实的组件测试看起来像这样:

typescript
import { render, screen, within } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { TodoList } from "@/components/TodoList";
 
describe("TodoList", () => {
  it("adds a new todo item", async () => {
    const user = userEvent.setup();
    render(<TodoList />);
 
    const input = screen.getByRole("textbox", { name: /new todo/i });
    const addButton = screen.getByRole("button", { name: /add/i });
 
    await user.type(input, "Write tests");
    await user.click(addButton);
 
    expect(screen.getByText("Write tests")).toBeInTheDocument();
    expect(input).toHaveValue("");
  });
 
  it("marks a todo as completed", async () => {
    const user = userEvent.setup();
    render(<TodoList initialItems={[{ id: "1", text: "Buy groceries", done: false }]} />);
 
    const checkbox = screen.getByRole("checkbox", { name: /buy groceries/i });
    await user.click(checkbox);
 
    expect(checkbox).toBeChecked();
  });
 
  it("removes completed items when clear button is clicked", async () => {
    const user = userEvent.setup();
    render(
      <TodoList
        initialItems={[
          { id: "1", text: "Done task", done: true },
          { id: "2", text: "Pending task", done: false },
        ]}
      />
    );
 
    await user.click(screen.getByRole("button", { name: /clear completed/i }));
 
    expect(screen.queryByText("Done task")).not.toBeInTheDocument();
    expect(screen.getByText("Pending task")).toBeInTheDocument();
  });
});

注意:没有内部状态检查,没有 component.setState(),没有检查实现细节。我们渲染、交互,断言用户能看到的东西。如果组件把内部状态管理从 useState 重构为 useReducer,这些测试仍然通过。这就是要点。

测试异步代码#

waitFor#

当组件异步更新时,waitFor 会轮询直到断言通过:

typescript
import { render, screen, waitFor } from "@testing-library/react";
 
it("loads and displays user profile", async () => {
  render(<UserProfile userId="123" />);
 
  // Initially shows loading
  expect(screen.getByText("Loading...")).toBeInTheDocument();
 
  // Wait for content to appear
  await waitFor(() => {
    expect(screen.getByText("John Doe")).toBeInTheDocument();
  });
 
  // Loading indicator should be gone
  expect(screen.queryByText("Loading...")).not.toBeInTheDocument();
});

waitFor 默认每 50ms 重试回调,直到通过或超时(默认 1000ms)。两者都可以自定义:

typescript
await waitFor(
  () => expect(screen.getByText("Done")).toBeInTheDocument(),
  { timeout: 3000, interval: 100 }
);

假定时器#

当测试使用 setTimeoutsetIntervalDate 的代码时,假定时器让你控制时间:

typescript
describe("Debounce", () => {
  beforeEach(() => {
    vi.useFakeTimers();
  });
 
  afterEach(() => {
    vi.useRealTimers();
  });
 
  it("delays execution by the specified time", () => {
    const callback = vi.fn();
    const debounced = debounce(callback, 300);
 
    debounced();
    expect(callback).not.toHaveBeenCalled();
 
    vi.advanceTimersByTime(200);
    expect(callback).not.toHaveBeenCalled();
 
    vi.advanceTimersByTime(100);
    expect(callback).toHaveBeenCalledOnce();
  });
 
  it("resets timer on subsequent calls", () => {
    const callback = vi.fn();
    const debounced = debounce(callback, 300);
 
    debounced();
    vi.advanceTimersByTime(200);
 
    debounced(); // reset the timer
    vi.advanceTimersByTime(200);
    expect(callback).not.toHaveBeenCalled();
 
    vi.advanceTimersByTime(100);
    expect(callback).toHaveBeenCalledOnce();
  });
});

重要提示:始终在 afterEach 中调用 vi.useRealTimers()。在测试之间泄漏的假定时器会导致你调试过的最令人困惑的失败。

假定时器和异步渲染结合#

将假定时器与 React 组件测试结合需要小心。React 的内部调度使用真实定时器,所以你经常需要同时推进定时器和刷新 React 更新:

typescript
import { render, screen, act } from "@testing-library/react";
 
describe("Notification", () => {
  beforeEach(() => {
    vi.useFakeTimers();
  });
 
  afterEach(() => {
    vi.useRealTimers();
  });
 
  it("auto-dismisses after 5 seconds", async () => {
    render(<Notification message="Saved!" autoDismiss={5000} />);
 
    expect(screen.getByText("Saved!")).toBeInTheDocument();
 
    // Advance timers inside act() to flush React updates
    await act(async () => {
      vi.advanceTimersByTime(5000);
    });
 
    expect(screen.queryByText("Saved!")).not.toBeInTheDocument();
  });
});

用 MSW 做 API Mock#

对于测试数据获取,Mock Service Worker(MSW)在网络层拦截请求。这意味着你的组件的 fetch/axios 代码完全按生产方式运行——MSW 只是替换了网络响应:

typescript
import { http, HttpResponse } from "msw";
import { setupServer } from "msw/node";
import { render, screen } from "@testing-library/react";
 
const server = setupServer(
  http.get("/api/users", () => {
    return HttpResponse.json([
      { id: 1, name: "Alice", email: "alice@example.com" },
      { id: 2, name: "Bob", email: "bob@example.com" },
    ]);
  }),
  http.get("/api/users/:id", ({ params }) => {
    return HttpResponse.json({
      id: Number(params.id),
      name: "Alice",
      email: "alice@example.com",
    });
  })
);
 
beforeAll(() => server.listen());
afterEach(() => server.resetHandlers());
afterAll(() => server.close());
 
describe("UserList", () => {
  it("displays users from API", async () => {
    render(<UserList />);
 
    expect(await screen.findByText("Alice")).toBeInTheDocument();
    expect(await screen.findByText("Bob")).toBeInTheDocument();
  });
 
  it("shows error state when API fails", async () => {
    // Override the default handler for this one test
    server.use(
      http.get("/api/users", () => {
        return new HttpResponse(null, { status: 500 });
      })
    );
 
    render(<UserList />);
 
    expect(await screen.findByText(/failed to load/i)).toBeInTheDocument();
  });
});

MSW 比直接 mock fetchaxios 好,因为:

  1. 你的组件的实际数据获取代码在运行——你在测试真实的集成。
  2. 你可以通过按测试覆盖处理器来测试错误处理。
  3. 同样的处理器在测试和浏览器开发模式(比如 Storybook)中都能工作。

集成测试#

单元测试 vs 集成测试#

单元测试隔离单个函数或组件并 mock 其他一切。集成测试让多个单元协同工作,只 mock 外部边界(网络、文件系统、数据库)。

事实是:我在生产中见过的大多数 bug 发生在单元之间的边界,而不是内部。一个函数在隔离状态下完美工作,但因为调用者传递了略微不同格式的数据而失败。一个组件用 mock 数据渲染正常,但在实际 API 响应多了一层嵌套时就崩了。

集成测试能捕获这些 bug。它们比单元测试慢,失败时更难调试,但每个测试给出更多信心。

多个组件一起测试#

typescript
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { ShoppingCart } from "@/components/ShoppingCart";
import { CartProvider } from "@/contexts/CartContext";
 
// Only mock the API layer — everything else is real
vi.mock("@/lib/api", () => ({
  checkout: vi.fn().mockResolvedValue({ orderId: "ORD-123" }),
}));
 
describe("Shopping Cart Flow", () => {
  const renderCart = (initialItems = []) => {
    return render(
      <CartProvider initialItems={initialItems}>
        <ShoppingCart />
      </CartProvider>
    );
  };
 
  it("displays item count and total", () => {
    renderCart([
      { id: "1", name: "Keyboard", price: 79.99, quantity: 1 },
      { id: "2", name: "Mouse", price: 49.99, quantity: 2 },
    ]);
 
    expect(screen.getByText("3 items")).toBeInTheDocument();
    expect(screen.getByText("$179.97")).toBeInTheDocument();
  });
 
  it("updates quantity and recalculates total", async () => {
    const user = userEvent.setup();
 
    renderCart([
      { id: "1", name: "Keyboard", price: 79.99, quantity: 1 },
    ]);
 
    const incrementButton = screen.getByRole("button", { name: /increase quantity/i });
    await user.click(incrementButton);
 
    expect(screen.getByText("$159.98")).toBeInTheDocument();
  });
 
  it("completes checkout flow", async () => {
    const user = userEvent.setup();
    const { checkout } = await import("@/lib/api");
 
    renderCart([
      { id: "1", name: "Keyboard", price: 79.99, quantity: 1 },
    ]);
 
    await user.click(screen.getByRole("button", { name: /checkout/i }));
 
    expect(checkout).toHaveBeenCalledWith({
      items: [{ id: "1", quantity: 1 }],
    });
 
    expect(await screen.findByText(/order confirmed/i)).toBeInTheDocument();
    expect(screen.getByText("ORD-123")).toBeInTheDocument();
  });
});

在这个测试中,ShoppingCartCartProvider 和它们的内部组件(商品行、数量选择器、总计显示)都用真实代码协同工作。唯一的 mock 是 API 调用,因为我们不想在测试中发真正的网络请求。

何时使用集成 vs 单元测试#

使用 单元测试 当:

  • 逻辑复杂且有很多边界情况(日期解析器、状态机、计算)。
  • 你需要对特定函数行为的快速反馈。
  • 该单元相对独立,不严重依赖其他单元。

使用 集成测试 当:

  • 多个组件需要正确协同工作(带校验和提交的表单)。
  • 数据流经多个层(context -> 组件 -> 子组件)。
  • 你在测试用户工作流,不是函数的返回值。

实践中,健康的测试套件对功能有大量集成测试,对复杂工具函数有单元测试。组件本身通过集成测试来测试——如果集成测试已经覆盖了它,你不需要为每个小组件都写单独的单元测试。

覆盖率#

运行覆盖率#

bash
vitest run --coverage

你需要一个覆盖率提供者。Vitest 支持两种:

bash
# V8 — faster, uses V8's built-in coverage
npm install -D @vitest/coverage-v8
 
# Istanbul — more mature, more configuration options
npm install -D @vitest/coverage-istanbul

在 Vitest 配置中配置:

typescript
export default defineConfig({
  test: {
    coverage: {
      provider: "v8",
      reporter: ["text", "html", "lcov"],
      include: ["src/**/*.{ts,tsx}"],
      exclude: [
        "src/**/*.test.{ts,tsx}",
        "src/**/*.spec.{ts,tsx}",
        "src/test/**",
        "src/**/*.d.ts",
        "src/**/types.ts",
      ],
      thresholds: {
        statements: 80,
        branches: 75,
        functions: 80,
        lines: 80,
      },
    },
  },
});

Istanbul vs V8#

V8 覆盖率 使用 V8 引擎内置的检测功能。更快因为没有代码转换步骤。但对某些边界情况可能不太准确,特别是 ES 模块边界。

Istanbul 在运行测试之前用计数器对你的源代码进行插桩。更慢但更久经考验,分支覆盖率更准确。如果你在 CI 中强制执行覆盖率阈值,Istanbul 的准确性可能很重要。

我本地开发用 V8(更快反馈),CI 用 Istanbul(更准确的执行)。需要时可以按环境配置不同的提供者。

覆盖率到底意味着什么#

覆盖率告诉你测试期间哪些行代码被执行了。仅此而已。它不告诉你那些行是否被正确测试了。看这个:

typescript
function divide(a: number, b: number): number {
  return a / b;
}
 
it("divides numbers", () => {
  divide(10, 2);
  // No assertion!
});

这个测试给 divide 函数 100% 的覆盖率。它也完全没有测试任何东西。如果 divide 返回 null、抛出错误或发射导弹,测试也会通过。

覆盖率是一个有用的 负面指标:低覆盖率意味着肯定有未测试的路径。但高覆盖率不意味着你的代码测试得好。它只意味着每一行在某个测试中运行过。

行覆盖率 vs 分支覆盖率#

行覆盖率是最常见的指标,但分支覆盖率更有价值:

typescript
function getDiscount(user: User): number {
  if (user.isPremium) {
    return user.yearsActive > 5 ? 0.2 : 0.1;
  }
  return 0;
}

getDiscount({ isPremium: true, yearsActive: 10 }) 的测试覆盖了每一行(100% 行覆盖率)但只测试了三个分支中的两个。isPremium: false 路径和 yearsActive <= 5 路径都未测试。

分支覆盖率能捕获这个。它跟踪条件逻辑中每条可能的路径。如果你要强制执行覆盖率阈值,用分支覆盖率。

忽略生成的代码#

有些代码不应该计入覆盖率。生成的文件、类型定义、配置——这些会虚增你的指标而不增加价值:

typescript
// vitest.config.ts
coverage: {
  exclude: [
    "src/**/*.d.ts",
    "src/**/types.ts",
    "src/**/*.stories.tsx",
    "src/generated/**",
    ".velite/**",
  ],
}

你也可以在源代码中忽略特定行或块:

typescript
/* v8 ignore start */
if (process.env.NODE_ENV === "development") {
  console.log("Debug info:", data);
}
/* v8 ignore stop */
 
// Or for Istanbul
/* istanbul ignore next */
function devOnlyHelper() { /* ... */ }

谨慎使用。如果你发现自己忽略了大块代码,要么那些块需要测试,要么它们一开始就不应该在覆盖率报告中。

测试 Next.js#

Mock next/navigation#

使用 useRouterusePathnameuseSearchParams 的 Next.js 组件需要 mock:

typescript
import { vi } from "vitest";
 
vi.mock("next/navigation", () => ({
  useRouter: () => ({
    push: vi.fn(),
    replace: vi.fn(),
    back: vi.fn(),
    prefetch: vi.fn(),
    refresh: vi.fn(),
  }),
  usePathname: () => "/en/blog",
  useSearchParams: () => new URLSearchParams("?page=1"),
  useParams: () => ({ locale: "en" }),
}));

对于需要验证导航是否被调用的测试:

typescript
import { useRouter } from "next/navigation";
 
vi.mock("next/navigation", () => ({
  useRouter: vi.fn(),
}));
 
describe("LogoutButton", () => {
  it("redirects to home after logout", async () => {
    const mockPush = vi.fn();
    vi.mocked(useRouter).mockReturnValue({
      push: mockPush,
      replace: vi.fn(),
      back: vi.fn(),
      prefetch: vi.fn(),
      refresh: vi.fn(),
      forward: vi.fn(),
    });
 
    const user = userEvent.setup();
    render(<LogoutButton />);
 
    await user.click(screen.getByRole("button", { name: /log out/i }));
 
    expect(mockPush).toHaveBeenCalledWith("/");
  });
});

Mock next-intl#

对于使用 next-intl 的国际化组件:

typescript
vi.mock("next-intl", () => ({
  useTranslations: () => (key: string) => key,
  useLocale: () => "en",
}));

这是最简单的方式——翻译返回键本身,所以 t("hero.title") 返回 "hero.title"。在断言中检查翻译键而不是实际的翻译字符串。这让测试与语言无关。

如果你在特定测试中需要实际翻译:

typescript
vi.mock("next-intl", () => ({
  useTranslations: () => {
    const translations: Record<string, string> = {
      "hero.title": "Welcome to My Site",
      "hero.subtitle": "Building things for the web",
    };
    return (key: string) => translations[key] ?? key;
  },
}));

测试 Route Handler#

Next.js Route Handler 是接收 Request 并返回 Response 的普通函数。测试很直接:

typescript
import { GET, POST } from "@/app/api/users/route";
import { NextRequest } from "next/server";
 
describe("GET /api/users", () => {
  it("returns users list", async () => {
    const request = new NextRequest("http://localhost:3000/api/users");
    const response = await GET(request);
    const data = await response.json();
 
    expect(response.status).toBe(200);
    expect(data).toEqual(
      expect.arrayContaining([
        expect.objectContaining({ id: expect.any(Number), name: expect.any(String) }),
      ])
    );
  });
 
  it("supports pagination via search params", async () => {
    const request = new NextRequest("http://localhost:3000/api/users?page=2&limit=10");
    const response = await GET(request);
    const data = await response.json();
 
    expect(data.page).toBe(2);
    expect(data.items).toHaveLength(10);
  });
});
 
describe("POST /api/users", () => {
  it("creates a new user", async () => {
    const request = new NextRequest("http://localhost:3000/api/users", {
      method: "POST",
      body: JSON.stringify({ name: "Alice", email: "alice@test.com" }),
    });
 
    const response = await POST(request);
    const data = await response.json();
 
    expect(response.status).toBe(201);
    expect(data.name).toBe("Alice");
  });
 
  it("returns 400 for invalid body", async () => {
    const request = new NextRequest("http://localhost:3000/api/users", {
      method: "POST",
      body: JSON.stringify({ name: "" }),
    });
 
    const response = await POST(request);
    expect(response.status).toBe(400);
  });
});

测试 Middleware#

Next.js middleware 运行在 edge 上并处理每个请求。把它当函数测试:

typescript
import { middleware } from "@/middleware";
import { NextRequest } from "next/server";
 
function createRequest(path: string, headers: Record<string, string> = {}): NextRequest {
  const url = new URL(path, "http://localhost:3000");
  return new NextRequest(url, { headers });
}
 
describe("middleware", () => {
  it("redirects unauthenticated users from protected routes", async () => {
    const request = createRequest("/dashboard");
    const response = await middleware(request);
 
    expect(response.status).toBe(307);
    expect(response.headers.get("location")).toContain("/login");
  });
 
  it("allows authenticated users through", async () => {
    const request = createRequest("/dashboard", {
      cookie: "session=valid-token",
    });
    const response = await middleware(request);
 
    expect(response.status).toBe(200);
  });
 
  it("adds security headers", async () => {
    const request = createRequest("/");
    const response = await middleware(request);
 
    expect(response.headers.get("x-frame-options")).toBe("DENY");
    expect(response.headers.get("x-content-type-options")).toBe("nosniff");
  });
 
  it("handles locale detection", async () => {
    const request = createRequest("/", {
      "accept-language": "tr-TR,tr;q=0.9,en;q=0.8",
    });
    const response = await middleware(request);
 
    expect(response.headers.get("location")).toContain("/tr");
  });
});

测试 Server Actions#

Server Actions 是运行在服务端的异步函数。因为它们就是函数,你可以直接测试——但你可能需要 mock 服务端专有的依赖:

typescript
vi.mock("@/lib/db", () => ({
  db: {
    user: {
      update: vi.fn().mockResolvedValue({ id: "1", name: "Updated" }),
      findUnique: vi.fn().mockResolvedValue({ id: "1", name: "Original" }),
    },
  },
}));
 
vi.mock("next/cache", () => ({
  revalidatePath: vi.fn(),
  revalidateTag: vi.fn(),
}));
 
import { updateProfile } from "@/app/actions/profile";
import { revalidatePath } from "next/cache";
 
describe("updateProfile", () => {
  it("updates user and revalidates profile page", async () => {
    const formData = new FormData();
    formData.set("name", "New Name");
    formData.set("bio", "New bio text");
 
    const result = await updateProfile(formData);
 
    expect(result.success).toBe(true);
    expect(revalidatePath).toHaveBeenCalledWith("/profile");
  });
 
  it("returns error for invalid data", async () => {
    const formData = new FormData();
    // Missing required fields
 
    const result = await updateProfile(formData);
 
    expect(result.success).toBe(false);
    expect(result.error).toBeDefined();
  });
});

实用模式#

自定义 render 函数#

大多数项目需要相同的 provider 包裹每个组件。创建一个自定义 render:

typescript
// src/test/utils.tsx
import { render, type RenderOptions } from "@testing-library/react";
import { ReactElement } from "react";
import { ThemeProvider } from "@/contexts/ThemeContext";
import { CartProvider } from "@/contexts/CartContext";
 
interface CustomRenderOptions extends RenderOptions {
  theme?: "light" | "dark";
  initialCartItems?: CartItem[];
}
 
function AllProviders({ children, theme = "light", initialCartItems = [] }: {
  children: React.ReactNode;
  theme?: "light" | "dark";
  initialCartItems?: CartItem[];
}) {
  return (
    <ThemeProvider defaultTheme={theme}>
      <CartProvider initialItems={initialCartItems}>
        {children}
      </CartProvider>
    </ThemeProvider>
  );
}
 
export function renderWithProviders(
  ui: ReactElement,
  options: CustomRenderOptions = {}
) {
  const { theme, initialCartItems, ...renderOptions } = options;
 
  return render(ui, {
    wrapper: ({ children }) => (
      <AllProviders theme={theme} initialCartItems={initialCartItems}>
        {children}
      </AllProviders>
    ),
    ...renderOptions,
  });
}
 
// Re-export everything from testing library
export * from "@testing-library/react";
export { renderWithProviders as render };

现在每个测试文件从你的自定义 utils 导入,而不是从 @testing-library/react

typescript
import { render, screen } from "@/test/utils";
 
it("renders in dark mode", () => {
  render(<Header />, { theme: "dark" });
  // Header and all its children have access to ThemeProvider and CartProvider
});

测试自定义 Hook#

Vitest 配合 @testing-library/reactrenderHook 使用:

typescript
import { renderHook, act } from "@testing-library/react";
import { useCounter } from "@/hooks/useCounter";
 
describe("useCounter", () => {
  it("starts at initial value", () => {
    const { result } = renderHook(() => useCounter(10));
    expect(result.current.count).toBe(10);
  });
 
  it("increments", () => {
    const { result } = renderHook(() => useCounter(0));
 
    act(() => {
      result.current.increment();
    });
 
    expect(result.current.count).toBe(1);
  });
 
  it("decrements with floor", () => {
    const { result } = renderHook(() => useCounter(0, { min: 0 }));
 
    act(() => {
      result.current.decrement();
    });
 
    expect(result.current.count).toBe(0); // doesn't go below min
  });
});

测试 Error Boundary#

typescript
import { render, screen } from "@testing-library/react";
import { ErrorBoundary } from "@/components/ErrorBoundary";
 
const ThrowingComponent = () => {
  throw new Error("Test explosion");
};
 
describe("ErrorBoundary", () => {
  // Suppress console.error for expected errors
  beforeEach(() => {
    vi.spyOn(console, "error").mockImplementation(() => {});
  });
 
  afterEach(() => {
    vi.restoreAllMocks();
  });
 
  it("displays fallback UI when child throws", () => {
    render(
      <ErrorBoundary fallback={<div>Something went wrong</div>}>
        <ThrowingComponent />
      </ErrorBoundary>
    );
 
    expect(screen.getByText("Something went wrong")).toBeInTheDocument();
  });
 
  it("renders children when no error", () => {
    render(
      <ErrorBoundary fallback={<div>Error</div>}>
        <div>All good</div>
      </ErrorBoundary>
    );
 
    expect(screen.getByText("All good")).toBeInTheDocument();
    expect(screen.queryByText("Error")).not.toBeInTheDocument();
  });
});

快照测试(谨慎使用)#

快照测试名声不好,因为人们把它当作真正断言的替代品。对整个组件 HTML 输出的快照是维护负担——每次 CSS 类改动都会挂,没人会仔细审查 diff。

但有针对性的快照可以有用:

typescript
import { render } from "@testing-library/react";
import { formatCurrency } from "@/lib/format";
 
// Good — small, targeted snapshot of a pure function's output
it("formats various currency values consistently", () => {
  expect(formatCurrency(0)).toMatchInlineSnapshot('"$0.00"');
  expect(formatCurrency(1234.5)).toMatchInlineSnapshot('"$1,234.50"');
  expect(formatCurrency(-99.99)).toMatchInlineSnapshot('"-$99.99"');
  expect(formatCurrency(1000000)).toMatchInlineSnapshot('"$1,000,000.00"');
});
 
// Bad — giant snapshot nobody will review
it("renders the dashboard", () => {
  const { container } = render(<Dashboard />);
  expect(container).toMatchSnapshot(); // Don't do this
});

内联快照(toMatchInlineSnapshot)比文件快照好,因为期望值就在测试里可见。你一眼就能看到函数返回什么,不用打开单独的 .snap 文件。

测试哲学#

测试行为,而非实现#

这个原则重要到值得单独一节。看看同一功能的两种测试:

typescript
// Implementation test — brittle, breaks on refactors
it("calls setState with new count", () => {
  const setStateSpy = vi.spyOn(React, "useState");
  render(<Counter />);
  fireEvent.click(screen.getByText("+"));
  expect(setStateSpy).toHaveBeenCalledWith(expect.any(Function));
});
 
// Behavior test — resilient, tests what the user sees
it("increments the displayed count when plus button is clicked", async () => {
  const user = userEvent.setup();
  render(<Counter />);
  expect(screen.getByText("Count: 0")).toBeInTheDocument();
 
  await user.click(screen.getByRole("button", { name: "+" }));
 
  expect(screen.getByText("Count: 1")).toBeInTheDocument();
});

第一个测试在你从 useState 切换到 useReducer 时就会挂,即使组件工作方式完全一样。第二个测试只在组件的行为真正改变时才会挂。它不关心计数在内部如何管理——只关心点击"+"后数字增加。

检验标准很简单:你能在不改变测试的情况下重构实现吗? 如果能,你在测试行为。如果不能,你在测试实现。

测试奖杯#

Kent C. Dodds 提出了"测试奖杯"作为传统测试金字塔的替代方案:

    ╭─────────╮
    │  E2E    │   Few — expensive, slow, high confidence
    ├─────────┤
    │         │
    │ Integr. │   Most — good confidence-to-cost ratio
    │         │
    ├─────────┤
    │  Unit   │   Some — fast, focused, low-cost
    ├─────────┤
    │ Static  │   Always — TypeScript, ESLint
    ╰─────────╯

传统金字塔把单元测试放在底部(大量),集成测试在中间(少一些)。奖杯把这反过来:集成测试是最佳平衡点。原因如下:

  • 静态分析(TypeScript、ESLint)免费捕获拼写错误、类型错误和简单的逻辑错误。你甚至不需要运行任何东西。
  • 单元测试 适合复杂的纯逻辑但不能告诉你各部分是否能协同工作。
  • 集成测试 验证组件、hook 和 context 能否协同工作。每个测试给出最多的信心。
  • 端到端测试 验证整个系统但慢、不稳定、维护成本高。关键路径需要几个但不需要几百个。

我在实践中遵循这个分布:TypeScript 捕获大部分类型错误,我为复杂的工具函数和算法写单元测试,为功能和用户流程写集成测试,为关键路径(注册、购买、核心工作流)写少量 E2E 测试。

给你信心的 vs 浪费时间的#

测试存在是为了给你发布的信心。不是每行代码都运行过的信心——而是应用对用户来说能工作的信心。这是不同的东西。

高信心,高价值:

  • 结账流程的集成测试——覆盖表单校验、API 调用、状态更新和成功/错误 UI。
  • 价格计算函数的单元测试,包含边界情况——浮点数、四舍五入、折扣、零/负值。
  • 测试受保护路由重定向未认证用户。

低信心,浪费时间:

  • 静态营销页的快照测试——每次文案改动就挂,什么有意义的东西也没捕获。
  • 测试组件把 prop 传给子组件的单元测试——在测试 React 本身,不是你的代码。
  • 测试 useState 被调用——在测试框架,不是行为。
  • 配置文件的 100% 覆盖率——它是静态数据,TypeScript 已经验证了它的形状。

写测试之前要问的问题是:"如果这个测试不存在,什么 bug 可能溜进生产?" 如果答案是"TypeScript 不会捕获的——没有"或者"没人会注意到的——没有",这个测试可能不值得写。

测试即设计反馈#

难以测试的代码通常是设计不好的代码。如果你需要 mock 五个东西才能测试一个函数,那个函数有太多依赖。如果你无法在不设置复杂 context provider 的情况下渲染一个组件,那个组件和它的环境耦合太紧。

测试是你代码的用户。如果你的测试难以使用你的 API,其他开发者也会。当你发现自己在和测试设置搏斗时,把它当作重构被测代码的信号,而不是添加更多 mock。

typescript
// Hard to test — function does too much
async function processOrder(orderId: string) {
  const order = await db.orders.findById(orderId);
  const user = await db.users.findById(order.userId);
  const inventory = await checkInventory(order.items);
  if (!inventory.available) {
    await sendEmail(user.email, "out-of-stock", { items: inventory.unavailable });
    return { success: false, reason: "out-of-stock" };
  }
  const payment = await chargeCard(user.paymentMethod, order.total);
  if (!payment.success) {
    await sendEmail(user.email, "payment-failed", { error: payment.error });
    return { success: false, reason: "payment-failed" };
  }
  await db.orders.update(orderId, { status: "confirmed" });
  await sendEmail(user.email, "order-confirmed", { orderId });
  return { success: true };
}
 
// Easier to test — separated concerns
function determineOrderAction(
  inventory: InventoryResult,
  payment: PaymentResult
): OrderAction {
  if (!inventory.available) return { type: "out-of-stock", items: inventory.unavailable };
  if (!payment.success) return { type: "payment-failed", error: payment.error };
  return { type: "confirmed" };
}

第二个版本是纯函数。你可以测试库存和支付结果的每种组合而不需要 mock 数据库、支付提供商或邮件服务。编排逻辑(获取数据、发送邮件)在一个薄层中,在集成测试级别测试。

这就是测试的真正价值:不是在 bug 写完之后捕获它们,而是在提交之前阻止糟糕的设计。写测试的纪律推动你朝更小的函数、更清晰的接口和更模块化的架构发展。测试是副产品。设计改进才是主角。

写你信任的测试#

测试套件最糟糕的事不是有缺口,而是人们不再信任它。一个有几个随机在 CI 上失败的不稳定测试的套件,教会了团队忽略红色构建。一旦发生这种情况,测试套件比没有更糟——它主动提供了虚假的安全感。

如果测试间歇性失败,修复它或删除它。如果测试很慢,加速它或把它移到单独的慢测试套件。如果测试在每次不相关的改动时都挂,重写它来测试行为而不是实现。

目标是一个每次失败都意味着真正有东西坏了的测试套件。当开发者信任测试时,他们在每次提交前都会运行。当他们不信任测试时,他们用 --no-verify 绕过并双手合十部署。

构建一个你愿意拿你的周末来打赌的测试套件。任何达不到这个标准的都不值得维护。

相关文章