動態

詳情 返回 返回

👋 一起寫一個基於虛擬模塊的密鑰管理 Rollup 插件吧(四) - 動態 詳情

上一章 我們成功將插件遷移到 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 專注於為非瀏覽器邏輯提供最佳的、快速的開發體驗。

單元測試

在編寫插件或工具庫時,單元測試主要用於驗證每個獨立功能模塊的行為是否正確,它通常具有以下特點:

  1. 細粒度:測試目標是最小的可測試單元(函數、方法、類);
  2. 隔離性:各測試相互獨立,不依賴執行順序或外部環境;
  3. 可重複:相同的輸入應產生相同的輸出,便於迴歸測試;
  4. 快速執行:測試運行速度快,適合頻繁執行;
  5. 自動化:通常集成到構建或持續集成(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 testyarn testpnpm 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);
  });
});
  1. 空字符串 → 應返回空數組;
  2. 空數組 → 應還原為空字符串;
  3. 默認會拆成 4 段,並能正確合併;
  4. 可自定義段數(比如 6 段),也能正確合併;
  5. 同一個字符串多次拆分結果不同(説明有隨機性),但都能還原原文。

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 方式加載這個文件,檢查其中導出的變量 key1key2 是否與原始對象中的值完全一致,最後刪除臨時文件。

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;
}`
    );
  });
});
  1. 模擬文件操作:通過 vi.mock("fs-extra") 模擬 ensureFileoutputFile,避免實際讀寫磁盤。
  2. 測試默認路徑:當 dts: true 時,writeDeclaration() 應生成默認文件名 crypto-key.d.ts,並寫入對應的模塊聲明和鍵值類型。
  3. 測試自定義路徑:當 dts 是字符串(自定義路徑)時,應生成指定路徑的聲明文件,並寫入正確內容。
  4. 驗證調用:通過 expect(...).toHaveBeenCalledWith(...) 檢查 ensureFileoutputFile 是否被正確調用,確保文件路徑和內容符合預期。

運行測試與結果

Vitest 通過 v8 支持原生代碼覆蓋率,通過 istanbul 支持檢測代碼覆蓋率。

這裏我們選擇 Vitest 默認的 v8 作為覆蓋工具,在 vitest.config.ts 中配置 providerv8 並指定 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,我們下一個項目再見!👋

user avatar freeman_tian 頭像 qian5201314 頭像 me_life 頭像 dunizb 頭像 it1042290135 頭像 licin 頭像 beckyyyy 頭像 nzbin 頭像 taotao123 頭像 huaiyug 頭像 snowwolfarden 頭像 hu_qi 頭像
點贊 44 用戶, 點贊了這篇動態!
點贊

Add a new 評論

Some HTML is okay.