大家好,我是程序员柏风,AI 最全的 Vibecoding 学习地图 http://coderoadmap.cn 作者。
Vitest:前端单元测试入门
这一课你将能做到
- 在 Vite/React 项目中安装和配置 Vitest
- 编写测试用例,用断言验证函数和组件的行为
- 测试异步代码和 React 组件,生成覆盖率报告
为什么要学
手动测试的问题是:改一行代码,要点开十几个页面验证。代码量大了以后,没人记得所有边界情况,也不敢重构——怕改坏不知道。单元测试就是自动化脚本,每次改完代码跑一遍,几秒内告诉你有没有破坏已有功能。
Vitest 是 Vite 生态的测试框架,由 Vite 团队开发,速度极快,API 和 Jest 完全兼容。现代前端项目标配:Vite + Vitest + React Testing Library。
先建立图像
测试的基本结构:
describe(测试套件,一组相关测试)
└── it / test(单个测试用例)
├── 准备数据(Arrange)
├── 执行操作(Act)
└── 断言结果(Assert)
一个测试文件通常包含多个 describe 块,每个 describe 包含多个 it 用例。
跟着做
步骤 1:安装 Vitest
在 Vite + React 项目中:
npm install -D vitest @testing-library/react @testing-library/jest-dom jsdom
修改 package.json 添加脚本:
{
"scripts": {
"test": "vitest",
"test:run": "vitest run",
"coverage": "vitest run --coverage"
}
}
创建 vitest.config.js(或修改 vite.config.js):
import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";
export default defineConfig({
plugins: [react()],
test: {
environment: "jsdom",
globals: true,
setupFiles: "./src/test/setup.js"
}
});
创建 src/test/setup.js:
import "@testing-library/jest-dom";
步骤 2:第一个测试
创建 src/utils/math.js:
export function add(a, b) {
return a + b;
}
export function divide(a, b) {
if (b === 0) throw new Error("除数不能为 0");
return a / b;
}
创建 src/utils/math.test.js:
import { describe, it, expect } from "vitest";
import { add, divide } from "./math";
describe("math 工具函数", () => {
it("add 应该正确相加", () => {
expect(add(1, 2)).toBe(3);
expect(add(-1, 1)).toBe(0);
});
it("divide 应该正确相除", () => {
expect(divide(10, 2)).toBe(5);
});
it("divide 除数为 0 时应该抛出错误", () => {
expect(() => divide(10, 0)).toThrow("除数不能为 0");
});
});
运行:
npm run test
Vitest 会进入 watch 模式,文件变化时自动重新运行测试。npm run test:run 只跑一次。
步骤 3:常用断言
import { expect } from "vitest";
// 相等
expect(2 + 2).toBe(4); // 严格相等(===)
expect({ a: 1 }).toEqual({ a: 1 }); // 深度相等(对象/数组)
expect([1, 2]).toContain(2); // 数组包含
expect("hello world").toMatch(/world/); // 正则匹配
// 真假
expect(true).toBeTruthy();
expect(false).toBeFalsy();
expect(null).toBeNull();
expect(undefined).toBeUndefined();
expect(1).toBeDefined();
// 数字
expect(10).toBeGreaterThan(5);
expect(10).toBeLessThan(20);
expect(0.1 + 0.2).toBeCloseTo(0.3); // 浮点数精度
// 函数
const fn = vi.fn();
fn("hello");
expect(fn).toHaveBeenCalled();
expect(fn).toHaveBeenCalledWith("hello");
步骤 4:测试异步代码
// 被测函数
export async function fetchUser(id) {
const res = await fetch(`https://api.example.com/users/${id}`);
if (!res.ok) throw new Error("请求失败");
return res.json();
}
// 测试
import { vi } from "vitest";
it("fetchUser 应该返回用户数据", async () => {
// Mock fetch
global.fetch = vi.fn(() =>
Promise.resolve({
ok: true,
json: () => Promise.resolve({ id: 1, name: "张三" })
})
);
const user = await fetchUser(1);
expect(user.name).toBe("张三");
expect(fetch).toHaveBeenCalledWith("https://api.example.com/users/1");
});
步骤 5:测试 React 组件
创建 src/components/Counter.jsx:
import { useState } from "react";
export default function Counter({ initial = 0 }) {
const [count, setCount] = useState(initial);
return (
<div>
<p data-testid="count">计数:{count}</p>
<button onClick={() => setCount(count + 1)}>+1</button>
<button onClick={() => setCount(count - 1)}>-1</button>
</div>
);
}
创建 src/components/Counter.test.jsx:
import { render, screen, fireEvent } from "@testing-library/react";
import Counter from "./Counter";
describe("Counter 组件", () => {
it("应该显示初始计数", () => {
render(<Counter initial={5} />);
expect(screen.getByTestId("count")).toHaveTextContent("计数:5");
});
it("点击 +1 按钮应该增加计数", () => {
render(<Counter />);
const button = screen.getByText("+1");
fireEvent.click(button);
expect(screen.getByTestId("count")).toHaveTextContent("计数:1");
});
it("点击 -1 按钮应该减少计数", () => {
render(<Counter />);
fireEvent.click(screen.getByText("-1"));
expect(screen.getByTestId("count")).toHaveTextContent("计数:-1");
});
});
React Testing Library 的核心理念:像用户一样测试——找文字、点按钮、看结果,而不是测试内部实现细节。
步骤 6:生成覆盖率报告
npm install -D @vitest/coverage-v8
npm run coverage
终端会显示覆盖率表格,coverage/ 目录下有 HTML 报告。覆盖率指标:
- Statements:语句覆盖率
- Branches:分支覆盖率(if/else)
- Functions:函数覆盖率
- Lines:行覆盖率
建议目标:核心业务逻辑 80% 以上。
讲清楚要点
- 测试金字塔:单元测试(多、快、便宜)> 集成测试 > E2E 测试(少、慢、贵)。入门阶段先掌握单元测试。
- AAA 模式:Arrange(准备)、Act(执行)、Assert(断言)。每个测试用例都遵循这个结构,清晰可读。
- 测试行为,不是实现:不要测试「state 是否变成了 x」,而是测试「用户看到的文本是否变成了 x」。实现细节重构后测试不应失效。
- Mock 外部依赖:网络请求、定时器、本地存储等外部依赖都要 mock,保证测试快速、稳定、可重复。
- Vitest 和 Jest 的关系:API 几乎完全一样,Vitest 原生支持 ESM 和 TypeScript,在 Vite 项目中开箱即用,速度更快。
你可能会懵的地方
-
测试文件放哪里? 两种主流方式:1) 和源文件同目录(
math.js和math.test.js);2) 统一放__tests__目录。推荐同目录,方便查找和维护。 -
toBe和toEqual的区别?toBe是Object.is严格相等,适合原始类型;toEqual递归比较值,适合对象和数组。对象用toBe会失败,因为引用不同。 -
为什么测试 React 组件要用
data-testid? 当文字内容会变化(比如多语言)或没有唯一文字时,用data-testid是稳定的选择器。但优先用getByRole、getByText等语义化查询。 -
watch 模式太吵了,只想跑一次?
vitest run只执行一次并退出,适合 CI 环境。开发时用默认的 watch 模式,保存文件自动重跑。 -
测试通过了但代码有 bug? 测试只能验证你写了断言的场景。写测试时要思考边界情况:空数组、null、极大值、网络失败等。
错法对照
| 错法 | 问题 | 正解 |
|---|---|---|
测试里写 if 逻辑 | 测试本身可能有 bug | 每个用例只测一个场景,用数据驱动 |
| 测试依赖执行顺序 | 独立运行时失败 | 每个测试独立,用 beforeEach 重置状态 |
| 直接测试私有方法 | 重构后测试全挂 | 通过公开接口测试,私有方法是实现细节 |
| 异步测试不加 await | 测试提前结束,假阳性 | 所有 async 操作前加 await |
| Mock 所有东西 | 测试失去意义 | 只 mock 外部依赖(网络、时间、随机数) |
| 一个 it 里塞 10 个断言 | 失败时难以定位 | 一个 it 聚焦一个行为,或拆分多个 it |
| 忽略错误路径测试 | 只测 happy path | 必须测试错误和边界情况 |
卡点急救
- 测试报「Cannot find module」:检查 Vitest 配置中的
alias是否和 Vite 一致;检查文件扩展名(.jsx需要正确配置)。 - React 组件测试报「document is not defined」:环境没配
jsdom,检查vitest.config.js的test.environment。 - 异步测试超时:默认超时 5 秒,检查是否忘记
await;或显式增加超时it("...", async () => {...}, 10000)。 - 覆盖率报告空白:确认
@vitest/coverage-v8已安装;确认package.json的 coverage 脚本正确。
面试常问
-
单元测试、集成测试、E2E 测试的区别? 单元测试测单个函数/组件;集成测试测多个模块协作;E2E 测试模拟真实用户操作完整流程。金字塔:单元测试最多,E2E 最少。
-
TDD 是什么? 测试驱动开发:先写失败的测试 → 写最少代码让测试通过 → 重构。红-绿-重构循环。能提升代码质量和设计,但学习曲线较陡。
-
Mock、Stub、Spy 的区别? Mock:完全模拟一个对象;Stub:替代某个方法返回固定值;Spy:监听函数调用,不改变行为。Vitest 中
vi.fn()是 Mock/Spy,vi.spyOn()是 Spy。 -
为什么测试要快? 测试慢了就没人愿意跑。单元测试应该毫秒级,避免真实网络请求、文件 IO、数据库操作。慢测试移到集成/E2E 层。
自测三问
- 写一个函数
isEven(n)和它的完整测试用例(包含正数、负数、零)。 - 测试 React 组件时,为什么推荐用
getByText而不是直接查 DOM class? toBe和toEqual分别适用于什么场景?
小结一句话
测试是代码的安全网:用 AAA 结构写用例、用断言验证行为、用 mock 隔离依赖——改代码不再心惊胆战,重构有了底气。
延伸
- Vitest 官方文档
- React Testing Library 文档
- 下一课:综合练习——用 React + Tailwind 做一个完整项目。