一、为什么大型项目里的测试会越写越乱
咱们平时写代码,项目一开始都很清爽,测试也就那么几个文件,跑起来快得很。可一旦团队壮大、业务变多,测试就会慢慢变成一团乱麻。你可能会发现,明明自己只改了一行代码,可是本地跑测试的时候,有一大堆用例莫名其妙失败,这真是让人抓狂。
1.1 测试的“脏乱差”是怎么出现的
出现这种情况,往往是几个原因在作怪。第一,测试之间互相干扰。比如一个测试文件里改了一个全局变量,另一个文件里正好也要用这个变量,结果第二个文件一跑就出错。再比如,你有一个倒计时组件,测试里用到了 setTimeout,如果上一个测试没有把定时器清干净,下一个测试就会收到奇怪的报错。第二,项目里的依赖太多,每个依赖都要 mock,mock 一多,维护起来就特别累。第三,没有统一的初始化逻辑,有的测试需要登录状态,有的测试需要清空数据,这些步骤散落在各个地方,最后就是复制粘贴到处都是,谁也不愿意去动别人写的那一坨。
1.2 Vitest 能帮咱们解决什么
Vitest 这个测试框架,最吸引人的地方就是快,而且它跟 Vite 的模块体系配合得特别好。Vite 平时用来跑开发服务器,已经是很多大型项目的基础设施了。Vitest 直接站在了 Vite 的肩膀上,所以它处理模块、解析依赖的方式非常高效。更关键的是,Vitest 给了咱们很多可配置的接口,可以在项目变大之后,依然保持测试结构的清晰和运行效率。
二、从一个简单的测试开始
光说不练假把式。咱们从零开始,用 Vitest 写一个最简单的测试,你先把感觉找着。
2.1 安装和准备
技术栈:JavaScript(Vitest + Node.js)
先装依赖,在项目根目录里执行:
# 安装 vitest 到开发依赖里
npm install -D vitest
然后创建一个普通函数文件,比如 src/math.js:
// src/math.js
// 一个简单的加法函数,用来演示测试怎么写
/**
* 把两个数字加起来
* @param {number} a 第一个数字
* @param {number} b 第二个数字
* @returns {number} 相加的结果
*/
export function add(a, b) {
return a + b;
}
2.2 写第一个单元测试
再创建一个测试文件 tests/math.test.js:
// tests/math.test.js
// 引入 Vitest 的 API 和待测试的函数
import { describe, it, expect } from 'vitest';
import { add } from '../src/math.js';
// describe 把一组相关的用例包在一起
describe('加法函数 add', () => {
// it 表示一个具体的测试用例
it('应该能计算正数相加', () => {
expect(add(2, 3)).toBe(5);
});
it('应该能计算零和负数', () => {
expect(add(0, -5)).toBe(-5);
});
});
然后运行测试:
# 用 npx 直接跑 vitest,run 表示只跑一次,不进入监听模式
npx vitest run
如果一切正常,你会看到绿色的通过提示。这就像心里有了底,后面才能做硬菜。
2.3 再加点难度:异步测试和 mock
真实项目里,函数不都是同步的,很多业务要请求接口、读取文件。这时候测试也要跟上。
假设我们要测试一个用户数据加载函数:
// src/user.js
// 从某个接口拉取用户信息
export async function fetchUser(apiClient, userId) {
// 这里直接调用外部传入的 apiClient,方便在测试里替换成模拟对象
const response = await apiClient.get(`/user/${userId}`);
return response.data;
}
然后在测试文件里,咱们不想真的发网络请求,用一个 mock 对象来模拟:
// tests/user.test.js
import { describe, it, expect, vi } from 'vitest';
import { fetchUser } from '../src/user.js';
// 构造一个假的 apiClient
function createMockApiClient() {
return {
get: vi.fn(), // 这个函数可以被断言是否被调用,也能控制返回值
};
}
describe('fetchUser 函数', () => {
it('应该用正确的地址去请求用户数据', async () => {
const mockClient = createMockApiClient();
// 让 mock 的 get 方法返回一个固定的 Promise
mockClient.get.mockResolvedValue({ data: { id: 1, name: '张三' } });
const user = await fetchUser(mockClient, 1);
// 验证请求参数
expect(mockClient.get).toHaveBeenCalledWith('/user/1');
// 验证返回的数据
expect(user.name).toBe('张三');
});
});
这里用到了 vi.fn() 和 mockResolvedValue,这两个都是 Vitest 提供的功能。说白了,vi.fn() 能造出一个假的函数,你可以控制它返回什么,还能查它被调了几次、用了什么参数。这在大型项目里非常常用,因为只要你把接口调用的地方都收口到一个函数上,就能轻松替换。
三、为大型项目准备测试架构
小项目里测试文件随便放没毛病,但大型项目不一样。几百个测试文件如果散落各处,光是找问题就要浪费不少时间。所以咱们需要设计一个清晰的测试架构。
3.1 把测试文件安排得明明白白
我最推荐的方式是建一个 tests 目录,里面再按照功能模块分文件夹。比如:
# 建议的测试目录结构,用 tree 命令查看
tests/
unit/ # 单元测试,针对具体函数或模块
math.test.js
user.test.js
components/ # 组件测试,针对 UI 组件
Button.test.js
Input.test.js
integration/ # 集成测试,验证多个模块配合
checkout.test.js
setup.js # 测试环境初始化文件
这样一层层往下放,每个文件职责清楚,将来要找某个模块的测试,很快就能定位到。
3.2 用 setup 文件统一预处理
大型项目里,很多测试都要提前准备一些东西,比如模拟浏览器的 localStorage、清空某个缓存、设置环境变量。如果每个测试文件都写一遍,那会累死人。Vitest 的 setupFiles 就是为了解决这个问题。
技术栈:JavaScript(Vitest + Node.js)
在项目根目录创建 vitest.config.js:
// vitest.config.js
import { defineConfig } from 'vitest/config';
export default defineConfig({
test: {
// 在每一个测试文件开始之前,都会先执行 setup.js
setupFiles: ['./tests/setup.js'],
},
});
然后在 tests/setup.js 里写统一的准备工作:
// tests/setup.js
import { beforeEach, afterEach, vi } from 'vitest';
// 模拟浏览器环境里的 matchMedia,很多组件库都依赖它
vi.stubGlobal('matchMedia', () => ({
matches: false,
addEventListener() {},
removeEventListener() {},
dispatchEvent() {},
}));
// 每次测试前,给全局放一个空的缓存对象,方便业务代码使用
beforeEach(() => {
globalThis.__testCache = {};
});
// 每次测试后,把所有 stub 恢复原状,再把临时缓存删掉
afterEach(() => {
vi.unstubAllGlobals();
delete globalThis.__testCache;
});
你看,这样所有测试文件就共享了一套初始化逻辑。将来如果想给所有测试增加超时时间,也可以在 setup.js 里用 vi.setConfig 统一设。
3.3 别让测试互相踩脚:隔离策略
测试最忌讳的就是有“隐藏的依赖”。比如一个测试里往 globalThis 上挂了数据,另一个测试又去读,然后顺序不对就出问题。要避免这种情况,除了用 setup.js 清理,还可以通过 Vitest 的隔离机制。
Vitest 默认会为每个测试文件创建一个独立的 worker,所以大部分情况下,一个文件里的全局变量不会跑到另一个文件里。但如果你自己写的代码里有模块级别的缓存,那就得小心了。一个常用的做法是在测试里用 vi.resetModules() 来强制重新加载模块:
// tests/cache.test.js
import { describe, it, expect, vi, beforeEach } from 'vitest';
describe('模块缓存测试', () => {
beforeEach(() => {
// 让每个测试都重新加载目标模块,避免模块内部的 state 残留
vi.resetModules();
});
it('第一次调用会走初始化逻辑', async () => {
const service = await import('../src/service.js');
expect(service.getValue()).toBe('init');
});
it('第二次调用应该也是新的模块实例', async () => {
const service = await import('../src/service.js');
expect(service.getValue()).toBe('init');
});
});
这里 vi.resetModules() 会把模块缓存清掉,让每次 import 都拿到全新的副本。这招在处理单例对象时特别有用。
四、可扩展性的核心:插件和配置
一个测试架构能不能撑起大型项目,就看它好不好扩展。Vitest 在这一点上做得很聪明,几乎所有行为都可以通过配置文件来调整。
4.1 按项目特点拆分配置
假设你的项目里既有纯逻辑的单元测试,又有需要模拟浏览器的组件测试,这两类测试的环境要求不一样。你可以把配置拆成两份。
技术栈:JavaScript(Vitest + Node.js)
先写 vitest.unit.config.js:
// vitest.unit.config.js
import { defineConfig } from 'vitest/config';
export default defineConfig({
test: {
// 纯逻辑测试用 node 环境就够,速度快
environment: 'node',
include: ['tests/unit/**/*.test.js'],
},
});
再写 vitest.components.config.js:
// vitest.components.config.js
import { defineConfig } from 'vitest/config';
export default defineConfig({
test: {
// 组件测试需要模拟 DOM,用 jsdom 环境
environment: 'jsdom',
include: ['tests/components/**/*.test.js'],
},
});
然后在命令行里分别执行:
# 跑单元测试
npx vitest run --config vitest.unit.config.js
# 跑组件测试
npx vitest run --config vitest.components.config.js
这样一来,配置之间互不干扰,想给哪类测试加特殊设置都方便。有些团队还会把 setupFiles 也拆成不同的文件,进一步隔离逻辑。
4.2 用自定义环境应对复杂依赖
有些项目里会用到很多浏览器特有的对象,比如 window.screen、navigator 等。虽然 jsdom 能模拟大部分,但总有它模拟不了的东西。这时候就可以写一个自定义环境。
技术栈:JavaScript(Vitest + Node.js)
下面是一个简化版的自定义环境:
// custom-env.js
// 自定义一个测试环境,给全局塞点项目需要的东西
import { Environment } from 'vitest';
// 必须继承 Environment 类,并实现 setup 和 teardown
export default class CustomEnv extends Environment {
async setup() {
// 设置一个全局标识,业务代码可能会用它判断环境
this.global.__PROJECT_ENV__ = 'test';
// 模拟一个比较特殊的全局对象
this.global.__LEGACY_SDK__ = {
init() {},
run() {},
};
}
async teardown() {
// 测试跑完,把所有自定义全局变量清理掉
delete this.global.__PROJECT_ENV__;
delete this.global.__LEGACY_SDK__;
}
}
然后在配置里引用:
// vitest.config.js
import { defineConfig } from 'vitest/config';
import CustomEnv from './custom-env.js';
export default defineConfig({
test: {
environment: CustomEnv,
},
});
这一套思路,等于是给测试打造了一个“专属小房间”,屋里有什么都可以自己摆,跑完再收拾干净。
4.3 用 projects 管理 monorepo
如果你公司在用 monorepo,也就是把多个子项目放在一个仓库里管理,Vitest 的 projects 功能会很实用。它允许你在一个配置里同时引用多个子项目的测试配置。
技术栈:JavaScript(Vitest + Node.js)
示例:
// vitest.config.js
import { defineConfig } from 'vitest/config';
export default defineConfig({
test: {
projects: [
// 子项目 A 的测试配置
{
test: {
name: 'app-a',
root: './packages/app-a',
environment: 'node',
},
},
// 子项目 B 的测试配置
{
test: {
name: 'app-b',
root: './packages/app-b',
environment: 'jsdom',
},
},
],
},
});
这样你只需要跑一个命令,就能把每个子项目的测试都安排好。当然,projects 里的每个配置也可以写成单独的文件,再通过路径引用,灵活性很高。
五、跑大规模测试时的实用技巧
测试架构搭好了,接下来就得考虑“跑得动”。大型项目测试成千上万,不优化的话,一次全量可能要好几分钟,甚至十几分钟。
5.1 分片执行与多线程
Vitest 默认就会用多线程来跑测试,但你还可以手动分片。比如 CI 上有三台机器,每台跑三分之一:
# 第一台机器
npx vitest run --shard=1/3
# 第二台机器
npx vitest run --shard=2/3
# 第三台机器
npx vitest run --shard=3/3
这样三台机器并行,整个测试时间能缩短一大截。要注意的是,分片只影响 Vitest 对文件的分配,不会影响每个文件内部的隔离机制,所以不用担心重复跑或者漏跑。
5.2 只跑改动的测试
开发的时候,我们通常只想马上验证刚才改的代码。Vitest 的 watch 模式有这个本事。当你运行 npx vitest --changed 时,它会根据 Git 里变动的文件,自动找出相关的测试文件去跑。比如你改了 src/user.js,Vitest 会去跑所有加载了 user.js 的测试文件。这个功能用起来非常顺手,可以省下不少时间。
5.3 测试覆盖率与质量门槛
覆盖率是大型项目里绕不开的话题。Vitest 内置了覆盖率支持,默认使用 c8 来做数据采集。c8 是 Node.js 官方 V8 引擎覆盖率数据的一个采集工具,它不需要本地额外装什么编译器,跑起来特别干净。
技术栈:JavaScript(Vitest + Node.js)
在 vitest.config.js 里设置:
// vitest.config.js
import { defineConfig } from 'vitest/config';
export default defineConfig({
test: {
coverage: {
// 只统计 src 目录下的代码
include: ['src/**/*.js'],
// 设置质量门槛,低于这个比例会提示警告
thresholds: {
lines: 80,
functions: 80,
branches: 75,
statements: 80,
},
},
},
});
跑覆盖率:
# 生成覆盖率报告,命令行里会直接显示百分比
npx vitest run --coverage
很多团队的 CI 流程里都会加上这一条,降低覆盖率就发不了版,这样能倒逼大家多写有价值的测试。
六、应用场景、优缺点和注意事项
看完前面的内容,相信你已经对 Vitest 的架构能力有了感觉。现在咱们再聊聊实际使用中的取舍。
6.1 什么样的项目最适合用
Vitest 最适合以下场景。第一种是刚起步的纯前端项目,因为 Vite 本来就是这个时代的前端主流,Vitest 和它无缝衔接。第二种是组件库或者 UI 组件较多的项目,Vitest 对 JSX、TSX 支持很好,配个 jsdom 就能测组件。第三种是 monorepo 项目,用 projects 就能管理好几个子包,比以前的 Jest 配置要省心得多。如果你的项目还停留在 webpack 老架构上,也不是不能用,只是迁移的时候得评估一下成本。
6.2 优点和需要留意的坑
优点很明显,运行速度快,开发体验好,装一个依赖就能跑,不需要额外配 Babel。对 TypeScript 的支持也是原生的,不需要再装 ts-jest 这类额外插件。而且 Vitest 的 API 和 Jest 很像,前端工程师几乎是零门槛上手。
缺点当然也有。第一是生态比 Jest 小很多,如果你需要特别冷门的匹配器,可能得自己写或者找社区插件。第二是有些老项目里用了很多 webpack 自定义 loader,Vite 的方案不一定能兼容,迁移的时候可能会遇到一些奇怪的报错。第三是 vi.mock 在模拟复杂的模块依赖时,需要你非常清楚模块的加载顺序,不然很容易出现 mock 没生效的情况。
6.3 注意事项
最后提醒几点。
第一,别把测试文件写成一个巨大的文件,否则维护起来头大。第二,全局变量和 mock 一定要有清理机制,最好都放在 setup.js 里统一管。第三,在 CI 上使用分片时,要保证每片跑的时间差不多,否则一片跑完了另一片还在跑,浪费时间。第四,覆盖率阈值别定得太高,否则很容易靠写一堆没意义的断言来凑数字,反而不利于测试质量。第五,遇到 Vitest 版本升级的时候,先看 changelog,特别要关注 <root>、<project> 这类配置字段的变化,避免升级后配置失效。
七、总结
测试架构这件事,说难也难,说简单也简单。难的是在项目刚起步时就看清楚未来的需求,简单的是工具选对了,后面的事就顺了。Vitest 给咱们提供了一个灵活、快速、容易扩展的基础。咱们只要把文件分好类、把初始化逻辑收拢、把配置拆清楚、把运行方式调到最适合自己的状态,就算项目再大,测试也能一直保持清晰和高效。
希望这篇文章能让你在遇到大型项目测试问题时,心里多一份底气。反正工具是死的,人是活的,多试几次,总能找到一个适合自己的玩法。
评论
围绕“Vitest在大型项目中的架构设计与实践,确保测试的可扩展性”参与讨论