Vitest:替代 Jest 的前端測試工具新選擇

語言: CN / TW / HK

有一段時間沒更新文章了,最近在公司專案中對現有的測試框架從 jest 遷移到 vitest (一個 Monorepo 型別的專案,裡面測試大概有700組)。

最後僅僅從效能上來看,還是取得了不錯的成效,同樣也很大程度上減少了因為臃腫的 jest 帶來的很多配置心智負擔。

同時也發現其實現在社群中關於 vitest 的一些文章介紹還是比較少的,因此這篇文章中筆者會給大家介紹一下 vitest 這一測試框架,以及從 jest 到 vitest 遷移過程中的一些踩坑記錄,希望能有所幫助。

vitest 定位是個高效能的前端單元測試框架,具體官網地址可以參考: https://vitest.dev/。

目前在社群中也有一部分明星開源專案用上了,例如 vite 就在使用 vitest 作為測試框架來 "eat dog food"(具體參考 pr: https://github.com/vitejs/vite/pull/8076)

Vitest 除了本身相比於 jest 帶來了比較大的效能提升之外,同時還提供了很好的 ESM 支援。不過目前 vitest 官方並沒有給出具體對比的 benchmark,但在其官方的 twitter 頻道上能看到不少使用遷移後的使用者得到了極大的速度提升:

特性介紹

首先在 vitest 官網上是能看到關於其重點特性的一些介紹的,這裡筆者帶大家粗略過一下一些我覺得比較重要的且實用的特性。

ESM 優先支援

ESM 目前是前端模組的一個未來發展趨勢,已經有越來越多的包在打包輸出 esm 格式的產物,例如社群中有名的 ora、chalk 等庫。

關於 ESM 以及 CJS 的包產物格式可以參考 antfu 的這篇文章: https://antfu.me/posts/publish-esm-and-cjs。

不過目前很多的專案還是在使用 CJS,也有許多的專案正在開始向 ESM 進行遷移。而目前主流的測試框架 jest 對於 ESM 的支援實際上是一言難盡的。包括前面提到的 vite 倉庫本身從 jest 遷移到 vitest 很大原因也是由於 jest 本身的 esm 支援問題導致的:

關於 jest 對於 esm 的 native support 可以參考這個 issue: https://github.com/facebook/jest/issues/9430

而 vitest 則是天然對於 ESM 有著比較好的支援,其底層會使用 esbuild 進行檔案的 transform,不過由於 ESM 的優先支援,同樣給 vitest 帶來了不少的“問題”,這點後續介紹遷移的時候會詳細講解。

Vite 同步的配置檔案

對於本來使用 vite 作為構建工具的專案來說或許是個好處,因為這樣本質上就可以複用一份配置檔案了,例如專案使用 vite.config.ts ,那麼則可以直接配置 vitest 的相關配置即可,例如:

import { defineConfig } from 'vitest/config';
export default defineConfig({
 test: {
   // ...
 }
});

不過對於沒有使用 vite 構建的專案,是需要直接新建一個配置檔案的,不過需要注意的是,目前最新版本的 vitest 使用並不需要使用者在專案中安裝 vite 了,如果你只是使用 vitest 的話,那麼只用安裝 vitest 就行。

當然如果想單獨使用一份測試配置而不是和 vite 對應的構建配置共用一份,那麼可以使用一個叫做 vitest.config.ts 的配置檔案,vitest 會以該檔案為最高優先順序配置。

內建的 TypeScript / JSX 支援

一般 jest 的使用者如果需要測試 ts 或者 tsx 的程式碼邏輯的話,一般會需要使用到 ts-jest ,專案中還需要增加一份配置,例如一份 jest.config.js 配置:

module.exports = {
 transform: {
   '^.+\.(t|j)sx?$': 'ts-jest',
 },
 globals: {
   'ts-jest': {
     tsconfig: `${__dirname}/tsconfig.test.json`,
   },
 },
};

實際上現在很多應用都使用 TS 來進行開發了,使用 jest 每次都要增加一些冗餘配置以及額外的包引入,而如果使用 vitest 則就沒這方面的負擔。

即時的 watch 模式

對比而言,這個算是 vitest 的一個比較大的優勢,在 watch 模式下進行測試的熱更新,速度提升是要遠遠快於 jest 的,至於 vitest 的 watch 模式為什麼這麼快,可以參考 antfu 的一條 twitter 內容(https://twitter.com/antfu7/status/1468233216939245579):

圖片

和 vite 的原理類似,vitest 知道應用依賴的每個模組,因此它可以清楚地決定在檔案更改之後重新執行哪些模組的測試內容。這點對於正在開發的模組測試是非常實用的。

vitest 的使用 & 遷移

前面介紹了一些關於 vitest 的亮點特性,下面來給大家介紹一下 vitest 的使用操作,這裡就不從一個簡單的 demo 開始了,這些內容在官方文件上比較好找,筆者這裡不做過多展開。

實際上 vitest 的整體 API 都和 Jest 是比較對齊的,如果是一些比較小的專案去做遷移的話,vitest 官方提供了一篇相關的遷移流程文件: https://vitest.dev/guide/migration.html#migrating-from-jest。

這裡筆者結合自己在遷移過程中踩過的一些坑來對於 vitest 的使用以及遷移做一個比較確切的介紹,也希望對有這方面需求的讀者有幫助。

全域性 API 的適配

Jest 是預設開啟了全域性 API 的訪問,而 vitest 則是預設關閉的,因此如果你不開啟的話,在測試檔案中訪問一些關於 vitest 相關的 API,是會有拋錯的,預設情況下得寫成下面這種方式:

// 需要對 API 進行匯入
import { describe, expect } from 'vitest';
describe('test', () => {
 expect(1+1).toBe(1);
})

如果你的專案之前使用了 Jest,進行遷移過程中會有很多檔案需要進行重新匯入,簡單的解決方案就是在對應的 config 檔案中開啟 globals API 的訪問,同時 tsconfig 也需要設定對應的型別訪問:

// vitest.config.ts
import { defineConfig } from 'vitest/config';
export default defineConfig({
 test: {
   globals: true
 }
})
// tsconfig.json
{
 "compilerOptions": {
   "types": ["vitest/globals"]
 }
}

這樣就可以和 Jest 類似一樣使用全域性的測試 API 了。

Jest 相關 API 及型別替換

基本上很多 Jest 相關的 API 是可以做到直接替換的,舉個例子例如:

jest.mock()
jest.fn()
jest.spyOn()
// 這一類 API 可以直接替換為
vi.mock()
vi.fn()
vi.spyOn()

這裡如果圖簡單的話,我們可以直接在 vitest 的 setUp 指令碼中對全域性的 jest 對應做一個替換即可,這裡其實不是很推薦這種做法,如果只是短期的替換還是可以的:

// vitest.config.ts
import { defineConfig } from 'vitest/config';
export default defineConfig({
 test: {
   setupFiles: ['./vitest.setup.ts']
 }
});
// vitest.setup.ts
if(!global.jest) {
 global.jest = vi;
}

當然也有一些 Jest 中一些比較特殊的 API 在 vitest 中並沒有支援,這裡後續會做介紹,然後就是相關的型別宣告調整,vitest 的一些通用型別和 Jest 還是有一些區別,例如返回值的型別是相反的:

// jest
let jestFn: jest.Mock<string, [number]>
let jestFn: jest.SpyInstance<string, [number]>
// vitest
import type { SpyInstance, Mock } from 'vitest';
let vitestFn: Mock<string, [number]>
let vitestFn: SpyInstance<string, [number]>

這點可以具體參考 vitest 的遷移文件相關說明即可。

alias 相關配置替換

一般如果你使用了 tsconfig 中的 paths 配置,在 jest 的中同樣需要需要通過配置來宣告別名配置,不然 jest 在測試的時候會無法識別專案中的路徑寫法,例如一般這樣配置:

// jest.config.js
module.exports = {
 roots: ['<rootDir>/src'],
 moduleNameMapper: {
   '^src/(.*)': '<rootDir>/src/$1'
 }
}

一般這一類別名的處理在 vitest 需要藉助於 vite 的相關配置來完成:

// vitest.config.ts
import { defineConfig } from 'vitest/config';
import path from 'path';
export default defineConfig({
 resolve: {
   alias: {
     src: path.resolve(__dirname, 'src')
   }
 }
})

這樣基本就等同於上面 jest 的別名處理,同樣的因為 vitest 的底層是基於 vite 在做的(原始碼中使用到了 vite 的 createServer 方法),因此 vite 中很多配置都是可以等價進 vitest 中的。

snapshotSerializers 相容

在 jest 中提供了 snapshot 的一些序列化的配置,例如:

// jest.config.js
module.exports = {
 snapshotSerializers: ['jest-serializer-path']
}

在 vitest 對這一類庫的介面以及資料型別的匯出都是相容的,因此我們其實是可以直接在 vitest 中使用 jest 的對應的 snapshot 序列化相關的庫的,具體使用方法可以參考文件: https://vitest.dev/guide/migration.html#migrating-from-jest

藉助前面提到的 setup 檔案的相關配置:

// vitest.setup.ts
import serializer from 'jest-serializer-path';
expect.addSnapshotSerializer(serializer);

遷移踩坑及 workaround

在上面一節中主要介紹瞭如果把專案從 jest 遷移到 vitest 整體上需要做哪些事情,但實際上做完這些事情之後你的專案還是跑不起來測試,這裡筆者給大家談一下實際遷移過程中遇到的坑,希望可以對你有一些幫助。

庫的產物 CJS 引用出現拋錯

由於前面提到過 vitest 是一個以 ESM First 的測試框架,其實某種程度上來說,它並不是很支援 CJS 和 ESM 的一些混用情況,這裡出現的問題是在於 monorepo 下有個子包產出的產物內容是 cjs,因為 vitest 底層基於的 vite,vite 本身會使用 esbuild 去對一些庫檔案去 transform,這裡會把 cjs 的程式碼當作 esm 去進行處理,然後就出現了這裡的一個拋錯。

筆者這裡處理的方式比較簡單,直接把 CJS 匯出的包,產物改成了 ESM 格式,因為是在 Monorepo 內部使用的包,這裡修改並沒有特別大的風險。

不過筆者在社群中也看到有一些實踐者對 cjs 以及 esm 的混用情況提供了一些 workaround,具體可以參考這篇文章: https://blog.csdn.net/qq_21567385/article/details/124742193。

不過這裡更建議的方式還是得先擁抱了 native esm 再去嘗試 vitest 會比較好一些。

跨 workspace 引用 const enum 拋錯

如果你當前的測試的包引用了其他包裡面的一個 const enum 型別的變數,在 vitest 下進行 transform 的時候是會變成 undefined 的。舉個例子:

import { TestConst } from '@test/shared'
console.log(TestConst.TestA)
// @test/shared 包
export const enum TestConst {
 TestA = 'test_a',
}

這裡在 vitest 中進行測試的時候會拋錯: TypeError: Cannot read property 'TestA' of undefined 。

這裡前面提到過,因為 vitest 底層基於的 transform 工具是 esbuild ,esbuild 目前看來並不支援從第三包匯入的 const enum 的語句匯入編譯,參考 issue: https://github.com/evanw/esbuild/issues/128。

在 vitest 的 discord 中和 vitest 的核心開發者溝通之後發現這個問題確實是 vite 本身的一些限制導致:

因此這裡的解決方案其實也很簡單,直接修改第三包的 const enum 為 enum 就行,實際上並不會帶來特別大的體積損失,筆者這裡因為是內部的 Monorepo 包,因此調整也很簡單。

vi.mock 導致模組 undefined

如果你在一個用到了 vi.mock() 的測試檔案中匯入了其他的方法並且在 mock 中使用了,很大程度上 在 mock 上你是拿不到這些方法的,舉例:

import { mocktest } from '../test-a';
describe('Test', () => {
 it('xxx', async() => {
   vi.mock('@test-shared', () => {
     getTestFunc: vi.fn(),
     mocktest
   })
 })
})

很大程度上這裡會因為 mocktest 拿不到拋一個 ReferenceError ,具體也可以看 vitest 的相關 issue: https://github.com/vitest-dev/vitest/issues/1336 。

vitest 的核心工作者給出的意見是在這種情況下使用 vi.doMock() 替換掉 vi.mock() ,因為 vi.mock() 會出現提升到頂層而忽略其他 import 的情況:

同樣的有一些其他的奇怪 mock 問題拋錯也可以使用該方法來解決,例如拋錯 ReferenceError: Cannot access '__vite_ssr_import_1__' before initialization ,參考 issue: https://github.com/vitest-dev/vitest/issues/1084 。

jest 的 isolateModules 模組替換

這個 api 在 jest 中實際上比較冷門,因為 jest 實際上是在全域性共享一些變數例項的,例如有一些模組的 require 匯入 mock,實際上是會在一個測試檔案中的多個測試 case 共享的,因此想讓他們不共享的話,在 jest 中一般會使用 isolateModules 對這些模組的匯入做個隔離:

// xxx.test.ts
describe('test-case', () => {
 let mod: typeof import('../src/test-case');
 beforeEach(async () => {
   jest.isolateModule(() => {
      mod = require('../src/test-case');
   })
 })
})

而 vitest 中實際上因為 esm first 的特性,導致其檔案之間的例項共享都是單獨隔離開的,如果需要在檔案中對這樣的模組匯入 mock 做個隔離,可以使用 vi.resetModules() 這個方法,同樣也需要把 jest 中的 require 模組匯入修改成動態 import 匯入(ESM first):

// xxx.test.ts
describe('test-case', () => {
 let mod: typeof import('../src/test-case');
 beforeEach(async () => {
   vi.resetModules();
   mod = await import('../src/test-case');
 })
})

這樣實際上就能解決問題了,同樣參考 vitest 核心貢獻者建議:

總體來說,如果你想給你的新專案使用 vitest 或者將舊專案的測試方案從 jest 遷移到 vitest,筆者認為你可以從以下幾個方面著手:

  •  擁抱 native esm
  •  熟悉對應的遷移官方文件
  •  參考 vite 倉庫的用法(unit test 及 e2e test 的使用)

本質上 vitest 帶來的效能提升除了 vitest 研發團隊做的一些關於依賴圖的優化,更大程度上還是來源於 esbuild 的高效能,如果 jest 使用 swc-jest 的 preset 配置來進行檔案的 transform,可能從效能上並不一定會輸 vitest 很多,但 vitest 僅僅從配置的簡潔以及一些現代化的工具(例如 TS、JSX、ESM)的開箱即用,本質上是要比臃腫的 jest 要靈活不少的。

雖然目前 vitest 還處於一個初期迭代階段,但由於 vite 本身的使用以及社群中的一些流行框架的使用,筆者覺得 vitest 本身已經具備了在實際專案中使用的能力。

歡迎長按圖片加 ssh 為好友,我會第一時間和你分享前端行業趨勢,學習途徑等等。2022 陪你一起度過!