Vitest:替代 Jest 的前端測試工具新選擇
前言
有一段時間沒更新文章了,最近在公司項(xiàng)目中對現(xiàn)有的測試框架從 jest 遷移到 vitest (一個 Monorepo 類型的項(xiàng)目,里面測試大概有700組)。
最后僅僅從性能上來看,還是取得了不錯的成效,同樣也很大程度上減少了因?yàn)橛纺[的 jest 帶來的很多配置心智負(fù)擔(dān)。
同時也發(fā)現(xiàn)其實(shí)現(xiàn)在社區(qū)中關(guān)于 vitest 的一些文章介紹還是比較少的,因此這篇文章中筆者會給大家介紹一下 vitest 這一測試框架,以及從 jest 到 vitest 遷移過程中的一些踩坑記錄,希望能有所幫助。
vitest 定位是個高性能的前端單元測試框架,具體官網(wǎng)地址可以參考: https://vitest.dev/。
目前在社區(qū)中也有一部分明星開源項(xiàng)目用上了,例如 vite 就在使用 vitest 作為測試框架來 "eat dog food"(具體參考 pr: https://github.com/vitejs/vite/pull/8076)
Vitest 除了本身相比于 jest 帶來了比較大的性能提升之外,同時還提供了很好的 ESM 支持。不過目前 vitest 官方并沒有給出具體對比的 benchmark,但在其官方的 twitter 頻道上能看到不少使用遷移后的用戶得到了極大的速度提升:
特性介紹
首先在 vitest 官網(wǎng)上是能看到關(guān)于其重點(diǎn)特性的一些介紹的,這里筆者帶大家粗略過一下一些我覺得比較重要的且實(shí)用的特性。
ESM 優(yōu)先支持
ESM 目前是前端模塊的一個未來發(fā)展趨勢,已經(jīng)有越來越多的包在打包輸出 esm 格式的產(chǎn)物,例如社區(qū)中有名的 ora、chalk 等庫。
關(guān)于 ESM 以及 CJS 的包產(chǎn)物格式可以參考 antfu 的這篇文章: https://antfu.me/posts/publish-esm-and-cjs。
不過目前很多的項(xiàng)目還是在使用 CJS,也有許多的項(xiàng)目正在開始向 ESM 進(jìn)行遷移。而目前主流的測試框架 jest 對于 ESM 的支持實(shí)際上是一言難盡的。包括前面提到的 vite 倉庫本身從 jest 遷移到 vitest 很大原因也是由于 jest 本身的 esm 支持問題導(dǎo)致的:
關(guān)于 jest 對于 esm 的 native support 可以參考這個 issue: https://github.com/facebook/jest/issues/9430
而 vitest 則是天然對于 ESM 有著比較好的支持,其底層會使用 esbuild 進(jìn)行文件的 transform,不過由于 ESM 的優(yōu)先支持,同樣給 vitest 帶來了不少的“問題”,這點(diǎn)后續(xù)介紹遷移的時候會詳細(xì)講解。
Vite 同步的配置文件
對于本來使用 vite 作為構(gòu)建工具的項(xiàng)目來說或許是個好處,因?yàn)檫@樣本質(zhì)上就可以復(fù)用一份配置文件了,例如項(xiàng)目使用 vite.config.ts ,那么則可以直接配置 vitest 的相關(guān)配置即可,例如:
import { defineConfig } from 'vitest/config';
export default defineConfig({
test: {
// ...
}
});
不過對于沒有使用 vite 構(gòu)建的項(xiàng)目,是需要直接新建一個配置文件的,不過需要注意的是,目前最新版本的 vitest 使用并不需要用戶在項(xiàng)目中安裝 vite 了,如果你只是使用 vitest 的話,那么只用安裝 vitest 就行。
當(dāng)然如果想單獨(dú)使用一份測試配置而不是和 vite 對應(yīng)的構(gòu)建配置共用一份,那么可以使用一個叫做 vitest.config.ts 的配置文件,vitest 會以該文件為最高優(yōu)先級配置。
內(nèi)置的 TypeScript / JSX 支持
一般 jest 的用戶如果需要測試 ts 或者 tsx 的代碼邏輯的話,一般會需要使用到 ts-jest ,項(xiàng)目中還需要增加一份配置,例如一份 jest.config.js 配置:
module.exports = {
transform: {
'^.+\.(t|j)sx?$': 'ts-jest',
},
globals: {
'ts-jest': {
tsconfig: `${__dirname}/tsconfig.test.json`,
},
},
};
實(shí)際上現(xiàn)在很多應(yīng)用都使用 TS 來進(jìn)行開發(fā)了,使用 jest 每次都要增加一些冗余配置以及額外的包引入,而如果使用 vitest 則就沒這方面的負(fù)擔(dān)。
即時的 watch 模式
對比而言,這個算是 vitest 的一個比較大的優(yōu)勢,在 watch 模式下進(jìn)行測試的熱更新,速度提升是要遠(yuǎn)遠(yuǎn)快于 jest 的,至于 vitest 的 watch 模式為什么這么快,可以參考 antfu 的一條 twitter 內(nèi)容(https://twitter.com/antfu7/status/1468233216939245579):
圖片
和 vite 的原理類似,vitest 知道應(yīng)用依賴的每個模塊,因此它可以清楚地決定在文件更改之后重新運(yùn)行哪些模塊的測試內(nèi)容。這點(diǎn)對于正在開發(fā)的模塊測試是非常實(shí)用的。
vitest 的使用 & 遷移
前面介紹了一些關(guān)于 vitest 的亮點(diǎn)特性,下面來給大家介紹一下 vitest 的使用操作,這里就不從一個簡單的 demo 開始了,這些內(nèi)容在官方文檔上比較好找,筆者這里不做過多展開。
實(shí)際上 vitest 的整體 API 都和 Jest 是比較對齊的,如果是一些比較小的項(xiàng)目去做遷移的話,vitest 官方提供了一篇相關(guān)的遷移流程文檔: https://vitest.dev/guide/migration.html#migrating-from-jest。
這里筆者結(jié)合自己在遷移過程中踩過的一些坑來對于 vitest 的使用以及遷移做一個比較確切的介紹,也希望對有這方面需求的讀者有幫助。
全局 API 的適配
Jest 是默認(rèn)開啟了全局 API 的訪問,而 vitest 則是默認(rèn)關(guān)閉的,因此如果你不開啟的話,在測試文件中訪問一些關(guān)于 vitest 相關(guān)的 API,是會有拋錯的,默認(rèn)情況下得寫成下面這種方式:
// 需要對 API 進(jìn)行導(dǎo)入
import { describe, expect } from 'vitest';
describe('test', () => {
expect(1+1).toBe(1);
})
如果你的項(xiàng)目之前使用了 Jest,進(jìn)行遷移過程中會有很多文件需要進(jìn)行重新導(dǎo)入,簡單的解決方案就是在對應(yīng)的 config 文件中開啟 globals API 的訪問,同時 tsconfig 也需要設(shè)置對應(yīng)的類型訪問:
// vitest.config.ts
import { defineConfig } from 'vitest/config';
export default defineConfig({
test: {
globals: true
}
})
// tsconfig.json
{
"compilerOptions": {
"types": ["vitest/globals"]
}
}
這樣就可以和 Jest 類似一樣使用全局的測試 API 了。
Jest 相關(guān) API 及類型替換
基本上很多 Jest 相關(guān)的 API 是可以做到直接替換的,舉個例子例如:
jest.mock()
jest.fn()
jest.spyOn()
// 這一類 API 可以直接替換為
vi.mock()
vi.fn()
vi.spyOn()
這里如果圖簡單的話,我們可以直接在 vitest 的 setUp 腳本中對全局的 jest 對應(yīng)做一個替換即可,這里其實(shí)不是很推薦這種做法,如果只是短期的替換還是可以的:
// 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;
}
當(dāng)然也有一些 Jest 中一些比較特殊的 API 在 vitest 中并沒有支持,這里后續(xù)會做介紹,然后就是相關(guān)的類型聲明調(diào)整,vitest 的一些通用類型和 Jest 還是有一些區(qū)別,例如返回值的類型是相反的:
// 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]>
這點(diǎn)可以具體參考 vitest 的遷移文檔相關(guān)說明即可。
alias 相關(guān)配置替換
一般如果你使用了 tsconfig 中的 paths 配置,在 jest 的中同樣需要需要通過配置來聲明別名配置,不然 jest 在測試的時候會無法識別項(xiàng)目中的路徑寫法,例如一般這樣配置:
// jest.config.js
module.exports = {
roots: ['<rootDir>/src'],
moduleNameMapper: {
'^src/(.*)': '<rootDir>/src/$1'
}
}
一般這一類別名的處理在 vitest 需要借助于 vite 的相關(guān)配置來完成:
// vitest.config.ts
import { defineConfig } from 'vitest/config';
import path from 'path';
export default defineConfig({
resolve: {
alias: {
src: path.resolve(__dirname, 'src')
}
}
})
這樣基本就等同于上面 jest 的別名處理,同樣的因?yàn)?vitest 的底層是基于 vite 在做的(源碼中使用到了 vite 的 createServer 方法),因此 vite 中很多配置都是可以等價進(jìn) vitest 中的。
snapshotSerializers 兼容
在 jest 中提供了 snapshot 的一些序列化的配置,例如:
// jest.config.js
module.exports = {
snapshotSerializers: ['jest-serializer-path']
}
在 vitest 對這一類庫的接口以及數(shù)據(jù)類型的導(dǎo)出都是兼容的,因此我們其實(shí)是可以直接在 vitest 中使用 jest 的對應(yīng)的 snapshot 序列化相關(guān)的庫的,具體使用方法可以參考文檔: https://vitest.dev/guide/migration.html#migrating-from-jest
借助前面提到的 setup 文件的相關(guān)配置:
// vitest.setup.ts
import serializer from 'jest-serializer-path';
expect.addSnapshotSerializer(serializer);
遷移踩坑及 workaround
在上面一節(jié)中主要介紹了如果把項(xiàng)目從 jest 遷移到 vitest 整體上需要做哪些事情,但實(shí)際上做完這些事情之后你的項(xiàng)目還是跑不起來測試,這里筆者給大家談一下實(shí)際遷移過程中遇到的坑,希望可以對你有一些幫助。
庫的產(chǎn)物 CJS 引用出現(xiàn)拋錯
由于前面提到過 vitest 是一個以 ESM First 的測試框架,其實(shí)某種程度上來說,它并不是很支持 CJS 和 ESM 的一些混用情況,這里出現(xiàn)的問題是在于 monorepo 下有個子包產(chǎn)出的產(chǎn)物內(nèi)容是 cjs,因?yàn)?vitest 底層基于的 vite,vite 本身會使用 esbuild 去對一些庫文件去 transform,這里會把 cjs 的代碼當(dāng)作 esm 去進(jìn)行處理,然后就出現(xiàn)了這里的一個拋錯。
筆者這里處理的方式比較簡單,直接把 CJS 導(dǎo)出的包,產(chǎn)物改成了 ESM 格式,因?yàn)槭窃?Monorepo 內(nèi)部使用的包,這里修改并沒有特別大的風(fēng)險。
不過筆者在社區(qū)中也看到有一些實(shí)踐者對 cjs 以及 esm 的混用情況提供了一些 workaround,具體可以參考這篇文章: https://blog.csdn.net/qq_21567385/article/details/124742193。
不過這里更建議的方式還是得先擁抱了 native esm 再去嘗試 vitest 會比較好一些。
跨 workspace 引用 const enum 拋錯
如果你當(dāng)前的測試的包引用了其他包里面的一個 const enum 類型的變量,在 vitest 下進(jìn)行 transform 的時候是會變成 undefined 的。舉個例子:
import { TestConst } from '@test/shared'
console.log(TestConst.TestA)
// @test/shared 包
export const enum TestConst {
TestA = 'test_a',
}
這里在 vitest 中進(jìn)行測試的時候會拋錯: TypeError: Cannot read property 'TestA' of undefined 。
這里前面提到過,因?yàn)?vitest 底層基于的 transform 工具是 esbuild ,esbuild 目前看來并不支持從第三包導(dǎo)入的 const enum 的語句導(dǎo)入編譯,參考 issue: https://github.com/evanw/esbuild/issues/128。
在 vitest 的 discord 中和 vitest 的核心開發(fā)者溝通之后發(fā)現(xiàn)這個問題確實(shí)是 vite 本身的一些限制導(dǎo)致:
因此這里的解決方案其實(shí)也很簡單,直接修改第三包的 const enum 為 enum 就行,實(shí)際上并不會帶來特別大的體積損失,筆者這里因?yàn)槭莾?nèi)部的 Monorepo 包,因此調(diào)整也很簡單。
vi.mock 導(dǎo)致模塊 undefined
如果你在一個用到了 vi.mock() 的測試文件中導(dǎo)入了其他的方法并且在 mock 中使用了,很大程度上 在 mock 上你是拿不到這些方法的,舉例:
import { mocktest } from '../test-a';
describe('Test', () => {
it('xxx', async() => {
vi.mock('@test-shared', () => {
getTestFunc: vi.fn(),
mocktest
})
})
})
很大程度上這里會因?yàn)?mocktest 拿不到拋一個 ReferenceError ,具體也可以看 vitest 的相關(guān) issue: https://github.com/vitest-dev/vitest/issues/1336 。
vitest 的核心工作者給出的意見是在這種情況下使用 vi.doMock() 替換掉 vi.mock() ,因?yàn)?vi.mock() 會出現(xiàn)提升到頂層而忽略其他 import 的情況:
同樣的有一些其他的奇怪 mock 問題拋錯也可以使用該方法來解決,例如拋錯 ReferenceError: Cannot access '__vite_ssr_import_1__' before initialization ,參考 issue: https://github.com/vitest-dev/vitest/issues/1084 。
jest 的 isolateModules 模塊替換
這個 api 在 jest 中實(shí)際上比較冷門,因?yàn)?jest 實(shí)際上是在全局共享一些變量實(shí)例的,例如有一些模塊的 require 導(dǎo)入 mock,實(shí)際上是會在一個測試文件中的多個測試 case 共享的,因此想讓他們不共享的話,在 jest 中一般會使用 isolateModules 對這些模塊的導(dǎo)入做個隔離:
// xxx.test.ts
describe('test-case', () => {
let mod: typeof import('../src/test-case');
beforeEach(async () => {
jest.isolateModule(() => {
mod = require('../src/test-case');
})
})
})
而 vitest 中實(shí)際上因?yàn)?esm first 的特性,導(dǎo)致其文件之間的實(shí)例共享都是單獨(dú)隔離開的,如果需要在文件中對這樣的模塊導(dǎo)入 mock 做個隔離,可以使用 vi.resetModules() 這個方法,同樣也需要把 jest 中的 require 模塊導(dǎo)入修改成動態(tài) import 導(dǎo)入(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');
})
})
這樣實(shí)際上就能解決問題了,同樣參考 vitest 核心貢獻(xiàn)者建議:
總結(jié)
總體來說,如果你想給你的新項(xiàng)目使用 vitest 或者將舊項(xiàng)目的測試方案從 jest 遷移到 vitest,筆者認(rèn)為你可以從以下幾個方面著手:
- 擁抱 native esm
- 熟悉對應(yīng)的遷移官方文檔
- 參考 vite 倉庫的用法(unit test 及 e2e test 的使用)
本質(zhì)上 vitest 帶來的性能提升除了 vitest 研發(fā)團(tuán)隊做的一些關(guān)于依賴圖的優(yōu)化,更大程度上還是來源于 esbuild 的高性能,如果 jest 使用 swc-jest 的 preset 配置來進(jìn)行文件的 transform,可能從性能上并不一定會輸 vitest 很多,但 vitest 僅僅從配置的簡潔以及一些現(xiàn)代化的工具(例如 TS、JSX、ESM)的開箱即用,本質(zhì)上是要比臃腫的 jest 要靈活不少的。
雖然目前 vitest 還處于一個初期迭代階段,但由于 vite 本身的使用以及社區(qū)中的一些流行框架的使用,筆者覺得 vitest 本身已經(jīng)具備了在實(shí)際項(xiàng)目中使用的能力。
歡迎長按圖片加 ssh 為好友,我會第一時間和你分享前端行業(yè)趨勢,學(xué)習(xí)途徑等等。2022 陪你一起度過!