Vitest 测试指南:从零到生产级信心
单元测试、集成测试、Testing Library 组件测试、Mock 策略、覆盖率配置,以及真正能提升软件质量的测试哲学。
大多数团队写测试的方式和大多数人锻炼的方式差不多:他们知道应该做,不做的时候有负罪感,等到终于开始做了,第一天就用力过猛,到周五就放弃了。代码库最终只剩下一堆浅层快照测试(每次改个 CSS 类就会挂),几个没人信任的集成测试,以及 README 里一个不诚实的覆盖率徽章。
我两种情况都经历过。我发布过零测试的项目,每次部署都提心吊胆。我也待过追求 100% 覆盖率的团队,花在维护测试上的时间比写功能还多。两种方式都不行。真正有效的是在正确的地方、用正确的工具写正确的测试。
Vitest 改变了我对 JavaScript 测试的看法。不是因为它发明了新概念——基本原理从 Kent Beck 几十年前写下来之后就没变过。而是因为它消除了足够多的摩擦,让写测试不再像苦差事,而是成为开发循环的一部分。当你的测试运行器和开发服务器一样快、用同样的配置时,借口就不存在了。
这篇文章是我关于 Vitest 测试的全部知识,从初始设置到让一切都值得的测试哲学。
为什么选择 Vitest 而不是 Jest#
如果你用过 Jest,你已经了解 Vitest 的大部分 API。这是设计如此——Vitest 在 API 层面兼容 Jest。describe、it、expect、beforeEach、vi.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() 用于性能测试。不需要单独的库:
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 和配置单独的运行器,挺好的。
设置#
安装#
npm install -D vitest @testing-library/react @testing-library/jest-dom @testing-library/user-event jsdom配置#
在项目根目录创建 vitest.config.ts:
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,可以扩展它:
/// <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 的选择#
当 globals 为 true 时,你不需要导入 describe、it、expect、beforeEach 等——它们到处可用,就像 Jest 一样。设为 false 时,你需要显式导入:
// 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 识别它们:
{
"compilerOptions": {
"types": ["vitest/globals"]
}
}环境:jsdom vs happy-dom vs node#
Vitest 让你可以按测试或全局选择 DOM 实现:
node— 没有 DOM。用于纯逻辑、工具函数、API 路由以及任何不涉及浏览器的东西。jsdom— 标准选择。完整的 DOM 实现。更重但更完整。happy-dom— 比 jsdom 更轻更快但不够完整。一些边界情况(如Range、Selection或IntersectionObserver)可能不工作。
我默认全局用 jsdom,需要 node 时按文件覆盖:
// 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:
// 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(断言)。即使你不写明确的注释,结构也应该是可见的:
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 块,使用被测单元的名称:
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 实际分组#
纯粹主义者的规则说每个测试一个断言。实际的规则说:每个测试一个 概念。这是不同的。
// 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();
});第一个测试有三个断言但它们都验证一个东西:显示名称的格式。如果任何断言失败,你确切知道什么坏了。第二个测试是三个独立的测试硬塞在一起。如果第二个断言失败,你不知道是创建还是更新出了问题,而且第三个断言永远不会运行。
测试描述即文档#
好的测试套件就是活文档。不熟悉代码的人应该能通过阅读测试描述来理解功能的行为:
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 是一个记录调用的函数:
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");你可以给它一个返回值:
const mockFetch = vi.fn().mockResolvedValue({
ok: true,
json: () => Promise.resolve({ id: 1, name: "Test" }),
});或者让它在连续调用时返回不同的值:
const mockRandom = vi.fn()
.mockReturnValueOnce(0.1)
.mockReturnValueOnce(0.5)
.mockReturnValueOnce(0.9);vi.spyOn() — 监视真实方法#
当你想观察一个方法但不替换其行为时:
const consoleSpy = vi.spyOn(console, "warn");
validateInput("");
expect(consoleSpy).toHaveBeenCalledWith("Input cannot be empty");
consoleSpy.mockRestore();spyOn 默认保留原始实现。需要时可以用 .mockImplementation() 覆盖,之后用 .mockRestore() 恢复原始实现。
vi.mock() — 模块级别 Mock#
这是重头戏。vi.mock() 替换整个模块:
// 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():
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 所有导出。每个导出的函数变成返回 undefined 的 vi.fn()。这对你想要静默的模块(如分析或日志)很有用,不需要指定每个函数。
Clear vs Reset vs Restore#
这一点总是让人困惑:
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():
beforeEach(() => {
vi.clearAllMocks();
});
afterEach(() => {
vi.restoreAllMocks();
});过度 Mock 的危险#
这是我能给出的最重要的 mock 建议:每个 mock 都是你对测试撒的谎。 当你 mock 一个依赖时,你在说"我相信这个东西能正确工作,所以我用一个简化版本替代它。"如果你的假设是错的,测试通过但功能是坏的。
// 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 调用了 validate 和 format。但如果 processInput 以错误的顺序调用它们呢?如果它忽略了它们的返回值呢?如果校验应该阻止格式化步骤运行呢?测试不知道。你已经 mock 掉了所有有趣的行为。
经验法则:在边界处 mock,不要在中间 mock。 Mock 网络请求、文件系统访问和第三方服务。不要 mock 你自己的工具函数,除非有充分的理由(比如它们运行代价高或有副作用)。
测试 React 组件#
Testing Library 基础#
Testing Library 强制执行一个理念:用用户交互的方式测试组件。不检查内部状态,不检查组件实例,不做浅渲染。你渲染一个组件并通过 DOM 与它交互,就像用户那样。
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* — 返回元素,如果找不到就抛出错误。在你期望元素存在时使用:
// Throws if no button found — test fails with a helpful error
const button = screen.getByRole("button", { name: "Submit" });queryBy* — 返回元素或 null(如果找不到)。在你断言某东西不存在时使用:
// Returns null — doesn't throw
expect(screen.queryByText("Error message")).not.toBeInTheDocument();findBy* — 返回 Promise。用于异步出现的元素:
// Waits up to 1000ms for the element to appear
const successMessage = await screen.findByText("Saved successfully");
expect(successMessage).toBeInTheDocument();无障碍优先的查询#
Testing Library 按特定优先级顺序提供这些查询:
getByRole— 最佳查询。使用 ARIA 角色。如果你的组件通过角色找不到,它可能有无障碍问题。getByLabelText— 用于表单元素。如果你的输入没有标签,先修复那个。getByPlaceholderText— 可接受但较弱。用户输入时占位符会消失。getByText— 用于非交互元素。通过可见文本内容查找。getByTestId— 最后手段。当没有语义查询可用时使用。
// 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。区别很重要:
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 而不是 onChange,fireEvent.change 不会触发那些处理器,但 userEvent.type 会。
始终在开头调用 userEvent.setup() 并使用返回的 user 实例。这确保了正确的事件顺序和状态追踪。
测试组件交互#
一个真实的组件测试看起来像这样:
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 会轮询直到断言通过:
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)。两者都可以自定义:
await waitFor(
() => expect(screen.getByText("Done")).toBeInTheDocument(),
{ timeout: 3000, interval: 100 }
);假定时器#
当测试使用 setTimeout、setInterval 或 Date 的代码时,假定时器让你控制时间:
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 更新:
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 只是替换了网络响应:
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 fetch 或 axios 好,因为:
- 你的组件的实际数据获取代码在运行——你在测试真实的集成。
- 你可以通过按测试覆盖处理器来测试错误处理。
- 同样的处理器在测试和浏览器开发模式(比如 Storybook)中都能工作。
集成测试#
单元测试 vs 集成测试#
单元测试隔离单个函数或组件并 mock 其他一切。集成测试让多个单元协同工作,只 mock 外部边界(网络、文件系统、数据库)。
事实是:我在生产中见过的大多数 bug 发生在单元之间的边界,而不是内部。一个函数在隔离状态下完美工作,但因为调用者传递了略微不同格式的数据而失败。一个组件用 mock 数据渲染正常,但在实际 API 响应多了一层嵌套时就崩了。
集成测试能捕获这些 bug。它们比单元测试慢,失败时更难调试,但每个测试给出更多信心。
多个组件一起测试#
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();
});
});在这个测试中,ShoppingCart、CartProvider 和它们的内部组件(商品行、数量选择器、总计显示)都用真实代码协同工作。唯一的 mock 是 API 调用,因为我们不想在测试中发真正的网络请求。
何时使用集成 vs 单元测试#
使用 单元测试 当:
- 逻辑复杂且有很多边界情况(日期解析器、状态机、计算)。
- 你需要对特定函数行为的快速反馈。
- 该单元相对独立,不严重依赖其他单元。
使用 集成测试 当:
- 多个组件需要正确协同工作(带校验和提交的表单)。
- 数据流经多个层(context -> 组件 -> 子组件)。
- 你在测试用户工作流,不是函数的返回值。
实践中,健康的测试套件对功能有大量集成测试,对复杂工具函数有单元测试。组件本身通过集成测试来测试——如果集成测试已经覆盖了它,你不需要为每个小组件都写单独的单元测试。
覆盖率#
运行覆盖率#
vitest run --coverage你需要一个覆盖率提供者。Vitest 支持两种:
# 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 配置中配置:
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(更准确的执行)。需要时可以按环境配置不同的提供者。
覆盖率到底意味着什么#
覆盖率告诉你测试期间哪些行代码被执行了。仅此而已。它不告诉你那些行是否被正确测试了。看这个:
function divide(a: number, b: number): number {
return a / b;
}
it("divides numbers", () => {
divide(10, 2);
// No assertion!
});这个测试给 divide 函数 100% 的覆盖率。它也完全没有测试任何东西。如果 divide 返回 null、抛出错误或发射导弹,测试也会通过。
覆盖率是一个有用的 负面指标:低覆盖率意味着肯定有未测试的路径。但高覆盖率不意味着你的代码测试得好。它只意味着每一行在某个测试中运行过。
行覆盖率 vs 分支覆盖率#
行覆盖率是最常见的指标,但分支覆盖率更有价值:
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 路径都未测试。
分支覆盖率能捕获这个。它跟踪条件逻辑中每条可能的路径。如果你要强制执行覆盖率阈值,用分支覆盖率。
忽略生成的代码#
有些代码不应该计入覆盖率。生成的文件、类型定义、配置——这些会虚增你的指标而不增加价值:
// vitest.config.ts
coverage: {
exclude: [
"src/**/*.d.ts",
"src/**/types.ts",
"src/**/*.stories.tsx",
"src/generated/**",
".velite/**",
],
}你也可以在源代码中忽略特定行或块:
/* 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#
使用 useRouter、usePathname 或 useSearchParams 的 Next.js 组件需要 mock:
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" }),
}));对于需要验证导航是否被调用的测试:
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 的国际化组件:
vi.mock("next-intl", () => ({
useTranslations: () => (key: string) => key,
useLocale: () => "en",
}));这是最简单的方式——翻译返回键本身,所以 t("hero.title") 返回 "hero.title"。在断言中检查翻译键而不是实际的翻译字符串。这让测试与语言无关。
如果你在特定测试中需要实际翻译:
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 的普通函数。测试很直接:
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 上并处理每个请求。把它当函数测试:
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 服务端专有的依赖:
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:
// 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:
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/react 的 renderHook 使用:
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#
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。
但有针对性的快照可以有用:
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 文件。
测试哲学#
测试行为,而非实现#
这个原则重要到值得单独一节。看看同一功能的两种测试:
// 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。
// 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 绕过并双手合十部署。
构建一个你愿意拿你的周末来打赌的测试套件。任何达不到这个标准的都不值得维护。