上一章 我们成功将插件迁移到 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,我们下一个项目再见!👋