Vitest のドキュメント読む (読みたい所だけ)
動機: なんとなくの理解でテストコードを書いているのでちゃんと理解したいと思った。
Vitest v2.1.4
Webpack, Babel, TypeScript と Vue2, React で Jest は少しやっていて、 Vitest も同じやろとなんとなくの理解で進めていたらちょっと困ったのでやっていきする。
モノレポ構成でちょっと躓いた部分があるので、そこを解決する事も目的。
追記: 躓いた部分というのは考えれば当たり前の事なので解決済み。上記の「解決」は理解を深めて今後の再発防止を目指す事を言っている気がする。
Getting Started
自分に必要な部分のみ抜粋。
設定
- プロジェクト root の
vite.config.ts
を自動で参照して利用してくれる。 - また、設定ファイルはプロジェクト root の
vitest.config.{js,mjs,cjs,ts,cts,mts}
が自動で読まれる。vitest.config.ts
の優先度が一番高い? -
process.env.VITEST
またはdefineConfig
のmode
プロパティで定義された値が実行モードとして渡されるのでvite.config.ts
内で分岐できる。明示的に指定が無い場合はtest
になる。 - 既に Vite の構成ファイルがある場合はそこに
test
プロパティを追加して Vitest の設定を行なう事もできる。その場合は頭に/// <reference types="vitest" />
を書いて Vitest への型の参照をさせる。
Examples
Experimental な物 (主にブラウザモード) が例として書かれていて参考にして良い物か悩む。
Workspaces 対応
- Vitest Workspaces でモノレポ対応が出来る。
- 1 つのプロジェクト内で異なる Vitest 設定を設けることが出来る。
- 設定は
vitest.workspace.{js,ts,json}
に書く。複数形の vitest.workspaces ではなく、単数形の vitest.workspace が正しい。
個人的メモ
(TypeScript) Triple-Slash Directives
TypeScript の triple slash directive を理解できていないのでついでにこれもここで軽く整理。記載内容が煩雑になるけど。
- XML タグらしい。
- コンパイラ (tsc とか) が読み取ってよしなにしてくれる
- ファイルの先頭行にのみ書ける。 これの前には同じ Triple-Slash directives, またはコメントのみ書ける。コードや型の宣言の後に書くとただのコメントになって意味が無い。
- TypeScript 5.5 以降コンパイル時に preserve の指定が無いと
///...
は出力されなくなる。これは今回の自分にはあまり関係がない。
/// <reference path="..." />
- ファイル間の依存関係を示す。
- コンパイル処理中でファイルを追加するよう指示する。
- 相対パスの場合はこれが記述されているファイルの場所が基点になる。
/// <reference types="..." />
-
///<reference path="..." />
と同様に、パッケージへの依存関係を示す。 -
import
でパッケージを解決する事に似ていて、これは型宣言をインポートしてくる事と考えるのが良いらしい。 -
/// <reference types="node">
は@types/node/index.d.ts
を使用する事を宣言している事になる。
Vite の設定ファイル内で /// <reference types="vitest">
と書いて defineConfig
等を使うと、定義がマージ?上書き?されるという事? :nanmo_wakaran:
/// <reference lib="..." />
- TypeScript の組み込みの
lib
を明示的に組み込む。 -
/// <reference lib="es2017.string" />
とすると、 tsconfig の lib で指定していなくてもString.prototype.padStart
とかが使えるようになるのかな?
Features
- Vite の設定, transformers, resolvers, plugins
- アプリケーションと同じ設定 (= Vite の設定) でテストを実行
- watch モードある
- デフォルトの環境だと watch モードで起動する
-
process.env.CI
がある場合は単純な実行モードとして起動する。 -
vitest watch
,vitest run
で明示的に指定もできる。
- コンポーネントテストにも対応
- 設定不要で ESM, TS, JSX, PostCSS に対応。
- 実行のマルチスレッド化
-
node:child_process
で同時実行。--pool=threads
でnode:worker_threads
を使ったマルチスレッド化に対応。 - 各テストファイルの環境は分離されるので env が他のファイルに影響しない。分離しない事もできる。
-
- ベンチマーク対応 (experimental)
- スイートやテストのフィルタリング、タイムアウト、並行実行をサポート
- 並列実行はテストの記述で
.concurrent
を指定する。 (e.g.it.concurrent
,test.concurrent
,describe.concurrent
)
- 並列実行はテストの記述で
- Workspace 対応
- Jest 互換の Snapshot test
- アサーションとして Chai が組み込まれているのと、 Jest の expecct 互換 API
- 関数モックのため Tinyspy が組み込まれている
- DOM のモックに happy-dom, jsdom 対応
- ブラウザでコンポーネントテストを実行するための Browser Mode (experimental)
- v8 か istanbul でカバレッジ計測
- Rust みたいにアプリケーションコード中でテスト書ける
-
expect-type
で型のテストもできる。- あんまりピンときてない。異なる型を受け取ったり返したりする複雑なコードに良さそう?
- シャーディングまでできる。
環境変数
- Vite と同じように
VITE_
で始まる環境変数を.env
ファイルかた読み取る。 - 全て読み込みたい場合は
vite
からloadEnv
を持って来て vitest 設定ファイルのtest.env
で使う。
export default defineConfig(({ mode }) => ({
test: {
env: loadEnv(/* ... */)
}
}))
Workspace
- 単一の Vitest プロセスで複数のプロジェクト設定を行なう事ができる。
- モノレポ構成で異なる設定 (
resolve.alias
,plugins
,test.browser
のような) が行われている時に特に有効。
workspace を定義する
- プロジェクト root に
vitest.{workspace,projects}.{ts,js,json}
を作成。workspace
は単数形。 - default export でプロジェクトのパス、または vitest 設定ファイルのパスをリストで定義。パスは glob パターンで指定しても良い。
- この設定ファイルが無い場合でも
packages
ディレクトリ配下のディレクトリを異なるプロジェクトとして区別する。 - 尚、デフォルトだと workspace 設定で明示的に示さない限りプロジェクト root の vitest.config を考慮しない。
- プロジェクト名は一意である必要がある。プロジェクトを glob パターンで指定している場合、デフォルトだと各プロジェクトに最も近い package.json の
name
プロパティの値か、無ければディレクトリ名が使われる。 - 各プロジェクトの vitest.config では
defineConfig
じゃなくてdefineProject
を使う方が型が安全。 workspace の設定ではいくつか使用不可のプロパティがあるので。
設定
- プロジェクト root の設定は自動では利用されないので、各 (Vitest) プロジェクト事に設定を作成する。 (e.g.
packages/my-package-a/vitest.config.ts
) - 共通の設定がありそれを流用したいなら、
vitest/config
からdefineProject
,mergeConfig
をインポートして使うと良い。 - ワークスペース定義内で個別に Vitest プロジェクトの設定を行なっている場合、
extends
プロパティで vitest の設定を継承する事ができる。- glob パターンで一括で設定するよりも、個別にプロジェクト名付けて extends で設定を読み込ませる方が、 Vitest Workspaces の設定を一箇所で確認出来て良いかもしれない。
- 次はプロジェクト単位では非対応。
-
coverage
: ワークスペース全体に対して行われるため。 -
reporters
: root レベルでのみ対応のため。 -
resolveSnapshotPath
: root レベルのリゾルバのみ尊重される - その他テストランナーに影響しない物全て
-
継承元の vite.config, vietest.config で既に plugins
に @vitejs/plugin-vue
を設定している時に、
子孫の vitest.config で改めて @vitejs/plugin-vue
を設定すると
Failed to parse source for import analysis because the content contains invalid JS syntax. Install @vitejs/plugin-vue to handle .vue files.
というエラーが出る。
子孫の vitest.config の plugins
から @vitejs/plugin-vue
を除くと解消した。
Test Filtering
テストスイート、テストのためのフィルタリング、タイムアウト設定、並行実行。
CLI
- コマンド引数として文字列を渡すとファイル名でフィルタリング (
vitest <keyword>
) -
-t, --testNamePattern <pattern>
を使うとファイル名ではなくテスト名等でフィルタリングできる。
タイムアウト設定
テスト定義の際の引数でタイムアウト時間を設定できる。デフォルト 5 秒。
test('name', async () => { ... }, 1000)
フック類も同じ。デフォルト 5 秒。
beforeAll(() => { ... }, 1000)
スキップ、選択、未実装
Jest と同じような物がある。
スキップ
describe.skip('name', () => { ... })
test.skip('name', () => { ... })
選択
describe.only('name', () => { ... })
test.only('name', () => { ... })
未実装
describe.todo('name')
test.todo('name')
Snapshot
- 関数の結果が変わらない事を確認できる。
- 結果はスナップショットファイルに保存される。
- テスト実行時にスナップショットが一致しなければテストは失敗。この時、正とされるスナップショットを更新する必要がある場合は更新する事もできる。
スナップショットを使う
test('toUpperCase', () => {
const result = toUpperCase('foobar');
expect(result).toMatchSnapshot();
})
以下のようなファイルが作成される。これはリポジトリに含めて運用に乗せる。
exports['toUpperCase 1'] = '"FOOBAR"'
テストを作ってないコードのリファクタリング前にとりあえず出力させておいて、最低限スナップショットテストだけは出来るようにしておくと良さそう。
非同期でスナップショットテストを行う場合は expect
のコンテキストに注意する。 (スナップショットテストに限定されないけど)
インライン・スナップショット
toMatchSnapshot
の引数で実行結果を定義しておくパターン。
これを行うと、スナップショット更新時に Vitest はテストコードを直接更新する。
ファイル スナップショット
toMatchFileSnapshot
でスナップショットのファイルを指定できる。
ファイル名を予め指定できるので、テスト名等に "
, '
を含めている場合にエスケープしなくて良くなる。
画像スナップショット
jest-image-snapshot
で画像のスナップショットテストも行える。
expect
に toMatchImageSnapshot
が生える。
test('image snapshot', () => {
expect(readFileSync('./test/stubs/input-image.png'))
.toMatchImageSnapshot()
})
Mocking
日時
vi.useFakeTimers()
vi.useRealTimers()
vi.setSystemTime(date)
関数
2 通りある。
- 関数の呼び出しを追跡したい場合:
vi.spyOn()
- モックを実装したい場合:
vi.fn()
ここで言うモックは Vitest 監視下のモックなので、ダミーの実装が欲しいだけならこれを使わず別で用意するのが良い気がする。
vi.fn().mockImplementation(exportedFunction)
のような書き方で Vitest 監視下に持ってくる事は出来そう。
vi.spyOn()
引数や呼ばれたかそうでないか、呼ばれた回数を確認できる。
mock function を返す。
const human = {
say
};
function say() {
return 'hello';
}
describe('name', () => {
test('tes', () => {
const spy = vi.spyOn(human, 'say');
expect(spy.getMockName()).toEqual('say');
expect(human.say()).toEqual('hello');
expect(spy).toHaveBeenCalledTimes(1);
spy.mockImplementationOnce(() => 'bye');
expect(human.say()).toEqual('bye');
expect(spy).toHaveBeenCalledTimes(2);
})
})
vi.fn()
モック関数を作れる。
mock function を返す。
実装が無い場合、呼び出されると undefined
を返す。
const human = {
say
};
function say() {
return 'hello';
}
describe('name', () => {
test('test', () => {
const mock = vi.fn().mockImplementation(say);
expect(mock()).toEqual('hello');
expect(mock).toHaveBeenCalledTimes(1);
mock.mockImplementationOnce(() => 'bye');
expect(mock()).toEqual('bye');
expect(mock).toHaveBeenCalledTimes(2);
expect(mock()).toEqual('hello');
expect(mock).toHaveBeenCalledTimes(3);
})
})
Globals
vi.stubGlobal
で jsdom, node の globalThis に無い変数をモックできる。
vi.stubGlobal('MyGlobalVariables', {
myFn: vi.fn(),
myProp: vi.spyOn()
});
test('test', () => {
window.MyGlobalVariables;
// or
MyGlobalVariables;
})
モジュール
vi.mock(path: string, factory?: MockOptions | ((importOriginal: () => unknown) => unknown)) => void
- 呼び出されるライブラリを監視し、引数や出力のテスト、実装の再宣言が出来る。
自動モックアルゴリズム
-
vi.mock( ... )
した時、テスト対象のコードがモックされたモジュールをインポートしていて、このモジュールに関連付けられた__mocks__
ファイルや (第2引数の)factory
が無い場合、 Vitest はモジュールを呼び出して全てのexport
をモックする事で、モジュール自体をモックする。
この処理は次の原則が適用される。
- 全ての配列は空になる
- 全てのプリミティブ値とコレクションは同じ
- 全ての object はディープクローンされる
- クラスとその prototype のインスタンスはディープクローンされる
仮想モジュール
- Vitest は Vite の仮想モジュールに対応している。
- Jest と違いって
vi.mock
にvirtual: true
を渡す代わりに、モジュールが存在することを Vite に伝える必要がある。- Jest だとこう書く。
jest.mock('path/to/module', () => { ... }, { virtual: true })
- Jest だとこう書く。
詳しくはドキュメント参照。
ファイルシステム
- ファイルシステムの差異によるエラーを回避できる。
- アクセス権限、ディスク容量、 read/write エラー等、再現が難しいエッジケースをテストできる。
-
memfs
の使用を推奨。 - サンプル
HTTP リクエスト
- MSW 使う。
タイマー
- timeout や interval を含むコードをテストする場合、テストコード中で
setTimeout
,setInterval
を書くのではなくそれらをモックする偽のタイマーを使うと高速化できる。 - 詳細は
vi.useFakeTimers
API section を読む。 vi.useFakeTimers
vi.restoreAllMocks
vi.runAllTimers
vi.advanceTimersByTime
vi.advanceTimersTonetTimer
クラス
- クラス全体を 1 回の
vi.fn
の呼び出しでモック化できる。- ES のクラスは function, prototype まわりの糖衣構文で、結局 function だからという事らしい。
-
new
キーワードを尊重しないので、関数の本体ではnew.target
は常にundefined
になることに注意する。- コンストラクタ内で
new.target
を使うクラスを記述する事は避ける。
- コンストラクタ内で
- クラス全体をモックしており、クラスメソッドもモックとして定義している時に、そのメソッドの戻り値をモックしたい場合は
vi.mocked(someClassInstance.someMethod).mockReturnValue('some return value')
のように書ける。 - プロパティをモックする場合は
vi.spyOn(someClassInstance, 'somePropName', 'get').mockReturnValue('some value')
のようにする。
チートシート
モッキングの感覚を掴める気がする。
Mock part of a module
のセクションに書いてある↓これにまさにハマった。ので、今回ドキュメントを読み込もうと思った。
-
vi.setSystemTime
でモックしたシステム日時はテスト間で共有され、自動で初期化されない。 -
vi.stubGlobal
も異なるテスト間で自動で初期化されない。vi.unstubGlobals
,vi.unstubAllGlobals
する。
import.meta.env
)
環境変数 (2 通りある。
-
beforeEach
でimport.meta.env.ENV_NAME_HERE
に値を割り当てる。自動でリセットされないので、初期値を控えておいてbeforeEach
で再割当てしてリセットする。 - テスト毎に
vi.stubEnv('ENV_NAME_HERE', 'some value')
を書く。config でtest.unstubEnvs
をtrue
にしていると自動でリセットされる。またはbeforeEach
でvi.unstubAllEnvs
する。
Test Context
組み込み test context
it
, test
の第2引数のコールバックの引数の object から task
, expect
を利用できる。
expect
は text context を固定するのに使える。 concurrent でスナップショットテストする時など。
拡張 test context
test context を拡張するのに 2 つの方法がある。
test.extend
1. - Playwright のように独自の
test
API を fixtures と共に定義して、どこでも使い回せる。- この仕組みを使うと、各テストで共通して利用する事前データ (でかい JSON 等) を
test
の単位で共通化したりできそう。
- この仕組みを使うと、各テストで共通して利用する事前データ (でかい JSON 等) を
- 作成した独自の
test
関数を更に拡張して fixtures を追加したり上書きできる。 - fixture の定義を関数で行なっている場合、テストで明示的に参照されない限りは初期化関数は呼ばれない。
- 自動で全て初期化するオプションも提供されている。
beforeEach
, afterEach
2. interface LocalTestContext {
foo: string
}
beforeEach<LocalTestContext>(async (context) => {
// extend context
context.foo = 'bar'
})
it<LocalTestContext>('should work', ({ foo }) => {
console.log(foo) // 'bar'
})
スイート毎じゃなくテストのプロジェクト全体に型を反映することも出来るようだけど今回は使わなさそう。
拡張 Test Context では定数、変数だけでなく関数も渡せる。
use
に関数を渡せば OK 。
Test Context の定義時に型を持たせるには test.extend<T>( ... )
と書く。じゃないと use
で取得できる物が unknown
型になる。
import { test as base, vi } from 'vitest'
interface IExampleContext {
someFn: (arg1: string, arg2: number) => Promise<string>;
}
export const exampleContextTest = base.extend<IExampleContext>({
someFn: async ({ expect, task }, use) => {
use(async (arg1, arg2) => {
await vi.advanceTimersToNextTimerAsync();
return `arg1: ${arg1}, arg2: ${arg2}`;
});
}
})
テスト対象のモジュール・関数またはコンポーネント (A) が別のモジュール (B) に依存しており、その依存先モジュール (B) が外部の状態 (X) に依存する場合、それはテスト対象のモジュール・関数またはコンポーネント (A) 自体の実装と関係が無いため、依存先モジュール (B) をモックしてしまうのが良さそう。
その場合、依存先モジュール (B) のモックを __mocks__
ディレクトリで用意し、それらをモックとして読み込むコード (vi.mock
) を用意し、 Vitest の設定でそれらを "setupFiles"
に指定するとテストプロジェクト全体 (モノレポなら当該パッケージ内に限る) で外部に依存するモジュール (B) をモックできる。
モックしたモジュールの本来のコードのテストをしたい場合、そのままだとモックされたコードに対してテストを実行してしまうため、対象のテストコード中で必ず vi.unmock
する。
|- src/
|- __setupFiles__/
| |- local-mods.ts
|- libs/
| |- my-lib.ts
| |- __mocks__/
| | |- my-lib.ts
| |- __tests__/
| |- my-lib.spec.ts
|- components/
|- MyComponent.vue
|- __tests__/
|- MyComponent.spec.ts
export const fetchMessage = async (): Promise<string> => {
const apiClient = new ApiClient();
const { response } = await apiClient.fetchMessage();
return response.body;
};
export const fetchMessage = vi.fn(async () => {
return "message";
})
vi.unmock("../my-lib")
test("fetch message", async () => {
// これはこれで ApiClient をモックしないといけない
const actual = fetchMessage();
expect(actual).toBe(/ ** /)
})
vi.mock(import("../libs/my-lib"))
<template>
<div>{{ message }}</div>
</template>
<script setup lang="ts">
import { onMounted } from "vue";
import { fetchMessage } from "../libs/my-lib";
defineOptions({
name: "MyComponent"
});
const message = ref("");
onMounted(async () => {
message.value = await fetchMessage();
});
</script>
test("render message", async () => {
const wrapper = mount(MyComponent);
await vi.advanceTimersToNextTimerAsync();
expect(wrapper.text()).toContain("message");
})
Storybook が採用しているモッキングの仕組みの、 Node.js の subpath imports とは別の仕組みなので、 Storybook との併用を考えている場合はもしかしたら同じようなコードを複数書いたりする事になるかもしれない。
UI とロジックはどうにかうまく分離して、 UI のみ Storybook で管理・視覚回帰テストして、
ロジックに依存せざるを得ないコンポーネントは Vitest でつつくのがいいのか?
Container/Presentational パターンの出番か