一、为什么大型项目里的测试会越写越乱

咱们平时写代码,项目一开始都很清爽,测试也就那么几个文件,跑起来快得很。可一旦团队壮大、业务变多,测试就会慢慢变成一团乱麻。你可能会发现,明明自己只改了一行代码,可是本地跑测试的时候,有一大堆用例莫名其妙失败,这真是让人抓狂。

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.screennavigator 等。虽然 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 给咱们提供了一个灵活、快速、容易扩展的基础。咱们只要把文件分好类、把初始化逻辑收拢、把配置拆清楚、把运行方式调到最适合自己的状态,就算项目再大,测试也能一直保持清晰和高效。

希望这篇文章能让你在遇到大型项目测试问题时,心里多一份底气。反正工具是死的,人是活的,多试几次,总能找到一个适合自己的玩法。