Open15

Vitest のドキュメント読む (読みたい所だけ)

sotasota

動機: なんとなくの理解でテストコードを書いているのでちゃんと理解したいと思った。

Vitest v2.1.4

sotasota

Webpack, Babel, TypeScript と Vue2, React で Jest は少しやっていて、 Vitest も同じやろとなんとなくの理解で進めていたらちょっと困ったのでやっていきする。

sotasota

モノレポ構成でちょっと躓いた部分があるので、そこを解決する事も目的。

追記: 躓いた部分というのは考えれば当たり前の事なので解決済み。上記の「解決」は理解を深めて今後の再発防止を目指す事を言っている気がする。

sotasota

https://vitest.dev/guide/

Getting Started

自分に必要な部分のみ抜粋。

設定

  • プロジェクト root の vite.config.ts を自動で参照して利用してくれる。
  • また、設定ファイルはプロジェクト root の vitest.config.{js,mjs,cjs,ts,cts,mts} が自動で読まれる。 vitest.config.ts の優先度が一番高い?
  • process.env.VITEST または defineConfigmode プロパティで定義された値が実行モードとして渡されるので 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 を理解できていないのでついでにこれもここで軽く整理。記載内容が煩雑になるけど。

TypeScript: Documentation - Triple-Slash Directives

  • 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 とかが使えるようになるのかな?
sotasota

https://vitest.dev/guide/features.html

Features

  • Vite の設定, transformers, resolvers, plugins
    • アプリケーションと同じ設定 (= Vite の設定) でテストを実行
  • watch モードある
    • デフォルトの環境だと watch モードで起動する
    • process.env.CI がある場合は単純な実行モードとして起動する。
    • vitest watch, vitest run で明示的に指定もできる。
  • コンポーネントテストにも対応
  • 設定不要で ESM, TS, JSX, PostCSS に対応。
  • 実行のマルチスレッド化
    • node:child_process で同時実行。 --pool=threadsnode: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 で使う。
e.g.
export default defineConfig(({ mode  }) => ({
    test: {
        env: loadEnv(/* ... */)
    }
}))
sotasota

https://vitest.dev/guide/workspace.html

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 レベルのリゾルバのみ尊重される
    • その他テストランナーに影響しない物全て
sotasota

継承元の 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 を除くと解消した。

sotasota

https://vitest.dev/guide/filtering.html

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')
sotasota

https://vitest.dev/guide/snapshot.html

Snapshot

  • 関数の結果が変わらない事を確認できる。
  • 結果はスナップショットファイルに保存される。
  • テスト実行時にスナップショットが一致しなければテストは失敗。この時、正とされるスナップショットを更新する必要がある場合は更新する事もできる。

スナップショットを使う

test('toUpperCase', () => {
    const result = toUpperCase('foobar');
    expect(result).toMatchSnapshot();
})

以下のようなファイルが作成される。これはリポジトリに含めて運用に乗せる。

exports['toUpperCase 1'] = '"FOOBAR"'

テストを作ってないコードのリファクタリング前にとりあえず出力させておいて、最低限スナップショットテストだけは出来るようにしておくと良さそう。

非同期でスナップショットテストを行う場合は expect のコンテキストに注意する。 (スナップショットテストに限定されないけど)

インライン・スナップショット

toMatchSnapshot の引数で実行結果を定義しておくパターン。
これを行うと、スナップショット更新時に Vitest はテストコードを直接更新する。

ファイル スナップショット

toMatchFileSnapshot でスナップショットのファイルを指定できる。
ファイル名を予め指定できるので、テスト名等に ", ' を含めている場合にエスケープしなくて良くなる。

画像スナップショット

jest-image-snapshot で画像のスナップショットテストも行える。
expecttoMatchImageSnapshot が生える。

test('image snapshot', () => {
  expect(readFileSync('./test/stubs/input-image.png'))
    .toMatchImageSnapshot()
})
sotasota

https://vitest.dev/guide/mocking.html

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.mockvirtual: true を渡す代わりに、モジュールが存在することを Vite に伝える必要がある。
    • Jest だとこう書く。
      jest.mock('path/to/module', () => { ... }, { virtual: true })
      

詳しくはドキュメント参照。

ファイルシステム

  • ファイルシステムの差異によるエラーを回避できる。
  • アクセス権限、ディスク容量、 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') のようにする。

チートシート

https://vitest.dev/guide/mocking.html#cheat-sheet

モッキングの感覚を掴める気がする。

Mock part of a module のセクションに書いてある↓これにまさにハマった。ので、今回ドキュメントを読み込もうと思った。

  • vi.setSystemTime でモックしたシステム日時はテスト間で共有され、自動で初期化されない。
  • vi.stubGlobal も異なるテスト間で自動で初期化されない。 vi.unstubGlobals, vi.unstubAllGlobals する。

環境変数 (import.meta.env)

2 通りある。

  1. beforeEachimport.meta.env.ENV_NAME_HERE に値を割り当てる。自動でリセットされないので、初期値を控えておいて beforeEach で再割当てしてリセットする。
  2. テスト毎に vi.stubEnv('ENV_NAME_HERE', 'some value') を書く。config で test.unstubEnvstrue にしていると自動でリセットされる。または beforeEachvi.unstubAllEnvs する。
sotasota

https://vitest.dev/guide/test-context.html

Test Context

組み込み test context

it, test の第2引数のコールバックの引数の object から task, expect を利用できる。
expect は text context を固定するのに使える。 concurrent でスナップショットテストする時など。

拡張 test context

test context を拡張するのに 2 つの方法がある。

1. test.extend

  • Playwright のように独自の test API を fixtures と共に定義して、どこでも使い回せる。
    • この仕組みを使うと、各テストで共通して利用する事前データ (でかい JSON 等) を test の単位で共通化したりできそう。
  • 作成した独自の test 関数を更に拡張して fixtures を追加したり上書きできる。
  • fixture の定義を関数で行なっている場合、テストで明示的に参照されない限りは初期化関数は呼ばれない。
    • 自動で全て初期化するオプションも提供されている。

2. beforeEach, afterEach

interface LocalTestContext {
  foo: string
}

beforeEach<LocalTestContext>(async (context) => {
  // extend context
  context.foo = 'bar'
})

it<LocalTestContext>('should work', ({ foo }) => {
  console.log(foo) // 'bar'
})

スイート毎じゃなくテストのプロジェクト全体に型を反映することも出来るようだけど今回は使わなさそう。

sotasota

拡張 Test Context では定数、変数だけでなく関数も渡せる。
use に関数を渡せば OK 。

Test Context の定義時に型を持たせるには test.extend<T>( ... ) と書く。じゃないと use で取得できる物が unknown 型になる。

example-context-test.ts
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}`;
        });
    }
})
sotasota

テスト対象のモジュール・関数またはコンポーネント (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
src/libs/my-lib.ts
export const fetchMessage = async (): Promise<string> => {
    const apiClient = new ApiClient();
    const { response } = await apiClient.fetchMessage();
    return response.body;
};
src/libs/__mocks__/my-lib.ts
export const fetchMessage = vi.fn(async () => {
    return "message";
})
src/libs/__tests__/my-lib.spec.ts
vi.unmock("../my-lib")

test("fetch message", async () => {
    // これはこれで ApiClient をモックしないといけない
    const actual = fetchMessage();
    expect(actual).toBe(/ ** /)
})
src/__setupFiles__/local-mods.ts
vi.mock(import("../libs/my-lib"))
src/components/MyComponent.vue
<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>
src/components/__tests__/MyComponent.spec.ts
test("render message", async () => {
    const wrapper = mount(MyComponent);
    await vi.advanceTimersToNextTimerAsync();
    expect(wrapper.text()).toContain("message");
})

Storybook が採用しているモッキングの仕組みの、 Node.js の subpath imports とは別の仕組みなので、 Storybook との併用を考えている場合はもしかしたら同じようなコードを複数書いたりする事になるかもしれない。

sotasota

UI とロジックはどうにかうまく分離して、 UI のみ Storybook で管理・視覚回帰テストして、
ロジックに依存せざるを得ないコンポーネントは Vitest でつつくのがいいのか?