上一章 我们成功将插件迁移到 Unplugin 插件系统,使其同时支持 Vite、Rollup、Webpack、Esbuild 等多种构建工具,让更多用户都能轻松体验到我们基于虚拟模块的密钥管理方案。
然而,尽管我们的插件功能已经完整实现,但是在未来的迭代过程中仍然存在潜在风险。插件可能因为版本更新、构建工具差异或者代码修改而出现功能回归、虚拟模块解析异常或类型声明生成不正确等问题。
为了确保插件在各种环境下始终稳定可靠,本章我们将会为插件编写单元测试,及时发现和防止潜在问题,从而为插件的持续维护和升级提供安全保障!
框架选型
我们的插件设计之初便考虑为 Vite 提供优先支持,所以对于单元测试框架自然第一时间想到的就是 Vitest,那么 Vitest 有哪些优势呢?
- 与 Vite 通用的配置、转换器、解析器和插件。
 - 智能文件监听模式,就像是测试的 HMR!
 - 支持对 Vue、React、Svelte、Lit 等框架进行组件测试。
 - 开箱即用的 TypeScript / JSX 支持。
 - 支持套件和测试的过滤、超时、并发配置。
 - ...
 
Jest
Jest 在测试框架领域占据了主导地位,因为它为大多数 JavaScript 项目提供开箱即用的支持,具备舒适的 API(it 和 expect),且覆盖了大多数测试的需求(例如快照、模拟和覆盖率)。
在 Vite 项目中使用 Jest 是可能的,但是在 Vite 已为最常见的 Web 工具提供了支持的情况下,引入 Jest 会增添不必要的复杂性。如果你的应用由 Vite 驱动,那么配置和维护两个不同的管道是不合理的。如果使用 Vitest,你可以在同一个管道中进行开发、构建和测试环境的配置。
Cypress
Cypress 是基于浏览器的测试工具,这对 Vitest 形成了补充。如果你想使用 Cypress,建议将 Vitest 用于测试项目中不依赖于浏览器的部分,而将 Cypress 用于测试依赖浏览器的部分。
Cypress 的测试更加专注于确定元素是否可见、是否可以访问和交互,而 Vitest 专注于为非浏览器逻辑提供最佳的、快速的开发体验。
单元测试
在编写插件或工具库时,单元测试主要用于验证每个独立功能模块的行为是否正确,它通常具有以下特点:
- 细粒度:测试目标是最小的可测试单元(函数、方法、类);
 - 隔离性:各测试相互独立,不依赖执行顺序或外部环境;
 - 可重复:相同的输入应产生相同的输出,便于回归测试;
 - 快速执行:测试运行速度快,适合频繁执行;
 - 自动化:通常集成到构建或持续集成(CI)流程中。
 
快速上手
首先使用 npm 将 Vitest 安装到项目:
            
            
              shell
              
              
            
          
          # pnpm
pnpm add -D vitest
# yarn
yarn add -D vitest
# npm
npm install -D vitest
        然后可以编写一个简单的测试来验证将两个数字相加的函数的输出:
            
            
              typescript
              
              
            
          
          // sum.ts
export function sum(a: number, b: number) {
  return a + b;
}
        
            
            
              typescript
              
              
            
          
          // sum.test.ts
import { expect, it } from "vitest";
import { sum } from "./sum";
it("adds 1 + 2 to equal 3", () => {
  expect(sum(1, 2)).toBe(3);
});
        一般情况下,执行测试的文件名中必须包含
.test.或.spec.。
接下来,为了执行测试,将以下部分添加到 package.json 文件中:
            
            
              json5
              
              
            
          
          // package.json
{
  "scripts": {
    "test": "vitest"
  }
}
        最后,运行 npm run test、yarn test 或 pnpm test,具体取决于你的包管理器,Vitest 将打印此消息:
            
            
              shell
              
              
            
          
          ✓ sum.test.ts (1)
  ✓ adds 1 + 2 to equal 3
Test Files  1 passed (1)
     Tests  1 passed (1)
  Start at  02:15:44
  Duration  311ms
        我们轻松入门了使用 Vitest 编写单元测试!
开始编码
接下来我们为插件的各个模块编写单元测试,测试文件放在 test 目录中,使用 .test.ts 后缀命名。
crypto-splitter
            
            
              typescript
              
              
            
          
          // crypto-splitter.test.ts
import { describe, expect, it } from "vitest";
import { combine, split } from "../packages/crypto-splitter/src";
describe("crypto-splitter", () => {
  it("returns empty array for empty string", () => {
    expect(split("")).toEqual([]);
  });
  it("returns empty string for empty chunks", () => {
    expect(combine([])).toBe("");
  });
  it("splits into default 4 segments and combines correctly", () => {
    const key = "iamxiaohe";
    const chunks = split(key);
    expect(chunks).toHaveLength(4);
    expect(combine(chunks)).toBe(key);
  });
  it("splits into custom number of segments and combines correctly", () => {
    const key = "iamxiaohe";
    const chunks = split(key, { segments: 6 });
    expect(chunks).toHaveLength(6);
    expect(combine(chunks)).toBe(key);
  });
  it("different splits produce different chunks but combine correctly", () => {
    const key = "iamxiaohe";
    const chunks1 = split(key);
    const chunks2 = split(key);
    expect(chunks1).not.toEqual(chunks2);
    expect(combine(chunks1)).toBe(key);
    expect(combine(chunks2)).toBe(key);
  });
});
        - 空字符串 → 应返回空数组;
 - 空数组 → 应还原为空字符串;
 - 默认会拆成 4 段,并能正确合并;
 - 可自定义段数(比如 6 段),也能正确合并;
 - 同一个字符串多次拆分结果不同(说明有随机性),但都能还原原文。
 
getCode
            
            
              typescript
              
              
            
          
          // code.test.ts
import { unlink, writeFile } from "node:fs/promises";
import { join } from "node:path";
import { describe, expect, it } from "vitest";
import { getCode } from "../packages/shared/src";
describe("getCode", () => {
  it("should generate code that exports correct key values", async () => {
    const keys = {
      key1: "iamxiaohe",
      key2: "ilovexiaohe"
    };
    const temp = join(__dirname, "virtual-code.js");
    await writeFile(temp, getCode(keys));
    const { key1, key2 } = await import(temp);
    expect(key1).toBe(keys.key1);
    expect(key2).toBe(keys.key2);
    await unlink(temp);
  });
});
        先准备一个包含若干键值的对象 keys,调用 getCode(keys) 得到生成的代码字符串,然后将其写入临时文件 virtual-code.js。通过动态 import 方式加载这个文件,检查其中导出的变量 key1 和 key2 是否与原始对象中的值完全一致,最后删除临时文件。
writeDeclaration
            
            
              typescript
              
              
            
          
          // declaration.test.ts
import { ensureFile, outputFile } from "fs-extra";
import { describe, expect, it, vi } from "vitest";
import { writeDeclaration } from "../packages/shared/src";
vi.mock("fs-extra", () => ({
  ensureFile: vi.fn(),
  outputFile: vi.fn()
}));
describe("writeDeclaration", () => {
  it("should create a declaration file with default name when dts is true", async () => {
    await writeDeclaration(
      {
        key1: "iamxiaohe",
        key2: "ilovexiaohe"
      },
      {
        moduleId: "virtual:crypto-key",
        dts: true
      }
    );
    expect(ensureFile).toHaveBeenCalledWith("crypto-key.d.ts");
    expect(outputFile).toHaveBeenCalledWith(
      "crypto-key.d.ts",
      `declare module "virtual:crypto-key" {
  export const key1: string;
  export const key2: string;
}`
    );
  });
  it("should create a declaration file with custom path when dts is a string", async () => {
    await writeDeclaration(
      {
        key1: "iamxiaohe"
      },
      {
        moduleId: "virtual:crypto-key",
        dts: "types/crypto-key.d.ts"
      }
    );
    expect(ensureFile).toHaveBeenCalledWith("types/crypto-key.d.ts");
    expect(outputFile).toHaveBeenCalledWith(
      "types/crypto-key.d.ts",
      `declare module "virtual:crypto-key" {
  export const key1: string;
}`
    );
  });
});
        - 模拟文件操作 :通过 
vi.mock("fs-extra")模拟ensureFile和outputFile,避免实际读写磁盘。 - 测试默认路径 :当 
dts: true时,writeDeclaration()应生成默认文件名crypto-key.d.ts,并写入对应的模块声明和键值类型。 - 测试自定义路径 :当 
dts是字符串(自定义路径)时,应生成指定路径的声明文件,并写入正确内容。 - 验证调用 :通过 
expect(...).toHaveBeenCalledWith(...)检查ensureFile和outputFile是否被正确调用,确保文件路径和内容符合预期。 
运行测试与结果
Vitest 通过 v8 支持原生代码覆盖率,通过 istanbul 支持检测代码覆盖率。
这里我们选择 Vitest 默认的 v8 作为覆盖工具,在 vitest.config.ts 中配置 provider 为 v8 并指定 include 配置覆盖率报告中需要统计的文件范围:
            
            
              typescript
              
              
            
          
          // vitest.config.ts
import { defineConfig } from "vitest/config";
export default defineConfig({
  test: {
    coverage: {
      provider: "v8",
      include: [
        "packages/*/src/**/*.ts"
      ]
    }
  }
});
        然后在 package.json 中添加 coverage 配置:
            
            
              json5
              
              
            
          
          // package.json
{
  "scripts": {
    "test": "vitest",
    "test:coverage": "vitest run --coverage"
  }
}
        现在执行 test:coverage 就可以运行测试并且输出单元测试覆盖率啦!
            
            
              shell
              
              
            
          
          Coverage enabled with v8
 ✓ test/crypto-splitter.test.ts (5 tests) 2ms
 ✓ test/declaration.test.ts (2 tests) 2ms
 ✓ test/code.test.ts (1 test) 5ms
 Test Files  3 passed (3)
      Tests  8 passed (8)
   Start at  13:54:48
   Duration  279ms (transform 61ms, setup 0ms, collect 96ms, tests 9ms, environment 0ms, prepare 176ms)
 % Coverage report from v8
---------------------|---------|----------|---------|---------
File                 | % Stmts | % Branch | % Funcs | % Lines 
---------------------|---------|----------|---------|---------
All files            |     100 |      100 |     100 |     100 
 crypto-splitter/src |     100 |      100 |     100 |     100 
  combine.ts         |     100 |      100 |     100 |     100 
  split.ts           |     100 |      100 |     100 |     100 
 shared/src          |     100 |      100 |     100 |     100 
  code.ts            |     100 |      100 |     100 |     100 
  declaration.ts     |     100 |      100 |     100 |     100 
---------------------|---------|----------|---------|---------
        🎉 所有测试用例全部通过,并且测试覆盖率达到 100%!
这意味着插件的核心逻辑已全部经过验证,不仅功能正确,而且具备极高的稳定性与可维护性。
源码
插件的完整代码可以在 virtual-crypto-key 仓库中查看。赠人玫瑰,手留余香,如果对你有帮助可以给我一个 ⭐️ 鼓励,这将是我继续前进的动力,谢谢大家 🙏!
总结与回顾
至此,我们已经为插件建立了完善的单元测试体系,使用 Vitest 对各个核心模块进行了自动化验证,确保:
- 🔐 密钥拆分与还原逻辑正确无误
 - 🧩 生成虚拟模块代码行为符合预期
 - 🧾 类型声明文件生成逻辑正确
 - ✅ 整体代码质量和覆盖率达标
 
回顾整个系列,我们从需求分析、插件设计、虚拟模块实现,到 TypeScript 支持、多构建工具迁移,再到如今的测试验证,完整经历了一个现代化插件从无到有的开发全流程。
如果你一路读到了这里,那说明你已经具备独立开发一个可发布插件的能力,不仅了解了 Rollup / Vite 插件机制的底层逻辑,也掌握了 Unplugin 的跨构建工具开发模式和 Vitest 的测试方法。
未来,你完全可以基于本系列的思路继续扩展更多特性,比如:
- 支持更复杂的密钥混淆算法
 - 添加 CI 流程自动化测试
 - 发布到 npm 供更多开发者使用
 
祝贺你完成了这场关于插件设计、类型系统与测试驱动开发的完整旅程!
本系列到此完结,感谢你的阅读与坚持,我是 xiaohe0601,我们下一个项目再见!👋