Vitest: 現代前端測試框架

前言

有一段時間沒更新文章了,最近在公司項目中對現有的測試框架從 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,筆者認爲你可以從以下幾個方面着手:

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

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

本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源https://mp.weixin.qq.com/s/Ara_KWegpSRssIPz3Oddtw