上一章 我們成功將插件遷移到 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 安裝到項目:
# pnpm
pnpm add -D vitest
# yarn
yarn add -D vitest
# npm
npm install -D vitest
然後可以編寫一個簡單的測試來驗證將兩個數字相加的函數的輸出:
// sum.ts
export function sum(a: number, b: number) {
return a + b;
}
// 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 文件中:
// package.json
{
"scripts": {
"test": "vitest"
}
}
最後,運行 npm run test、yarn test 或 pnpm test,具體取決於你的包管理器,Vitest 將打印此消息:
✓ 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
// 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
// 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
// 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 配置覆蓋率報告中需要統計的文件範圍:
// vitest.config.ts
import { defineConfig } from "vitest/config";
export default defineConfig({
test: {
coverage: {
provider: "v8",
include: [
"packages/*/src/**/*.ts"
]
}
}
});
然後在 package.json 中添加 coverage 配置:
// package.json
{
"scripts": {
"test": "vitest",
"test:coverage": "vitest run --coverage"
}
}
現在執行 test:coverage 就可以運行測試並且輸出單元測試覆蓋率啦!
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,我們下一個項目再見!👋