Closed23

Jest本をVitestで試してみる

kazokmrkazokmr

目的

Jestではじめるテスト入門 の 第2章を Vitestで書いてみる。

理由

  • Jestは既に使っている
  • 個人の興味でVitestを触ってみたかった
  • 今後Svelteも使っていきたかったのとSvelteKitでプロジェクト作成するとVitestインストールするか聞いてくるので
  • 「はじめに」にある著者のコメントを読んでVitestでも実行できるか試してみたくなったから

また、Vitest は Jest と互換性のある API で設計されているため、Jestを学ぶことは他のテストフレームワークを利用する際にも役立ちます。

自分の環境や使うツール

  • Mac book (CPU:M1 Pro, Mem: 32GB, OS: Ventura 13.3.1)
  • IntelliJ IDEA Ultimate 2023.1.1
  • Node.js 18.16.0
  • NPM 9.6.5

開発のベースになる依存関係

  • TypeScript 5.0.4
  • Vite 4.3.4
  • Vitest 0.30.1
kazokmrkazokmr

Projectを作成する

IntelliJ の New Project で作成

  • Generators: Vite
  • Name: hello-vitest-ts
  • Node interpreter: node 18.16.0
  • Vite: npx create-vite (4.3.1)
  • Template: vanilla
  • Use TypeScript template: checked

作成後に npm install で初期化を実行した

不要なファイルを削除

Viteでプロジェクトを作成すると Webアプリ用のファイルまで作成されてしまうので、src はいかの index.html や .tsファイルは削除してしまう。
ちなみにViteを入れたのはVitestに必要だっただけなので Viteでプロジェクトを作成しない方がよかったかも

依存関係の追加とアップデート

Vitestを追加 npm install -D vitest
TypeScriptとViteをUpgrade

package.json の修正

enginespackageManager プロパティを追加した。(個人的な好み)

開始時点の package.json は次の通り

pakcage.json
{
  "name": "hello-vitest-ts",
  "private": true,
  "version": "0.0.0",
  "engines": {
    "node": "18.x"
  },
  "packageManager": "npm@9.6.5",
  "type": "module",
  "scripts": {
    "dev": "vite",
    "build": "tsc && vite build",
    "preview": "vite preview"
  },
  "devDependencies": {
    "typescript": "^5.0.4",
    "vite": "^4.3.4",
    "vitest": "^0.30.1"
  }
}

tsconfig.json

プロジェクト作成時点の設定をベースに始めてみる。本の記載内容とは異なるが進めながら適宜修正していく

tsconfig.json
{
  "compilerOptions": {
    "target": "ES2020",
    "useDefineForClassFields": true,
    "module": "ESNext",
    "lib": [
      "ES2020",
      "DOM",
      "DOM.Iterable"
    ],
    "skipLibCheck": true,
    /* Bundler mode */
    "moduleResolution": "node",
    "allowImportingTsExtensions": true,
    "resolveJsonModule": true,
    "isolatedModules": true,
    "noEmit": true,
    /* Linting */
    "strict": true,
    "noUnusedLocals": true,
    "noUnusedParameters": true,
    "noFallthroughCasesInSwitch": true
  },
  "include": [
    "src/**/*.ts"
  ]
}

TypeScriptの設定確認はスキップした

kazokmrkazokmr

初めてのテスト

Vitestのセットアップ

Vitest の Getting Started を参考に vite.config.ts を用意して、Config の Propertyに test を追加しただけ

vite.config.ts
import {defineConfig} from "vitest/config";

export default defineConfig({
    test: {}
})

Scriptの追加

npm run test で vitest を実行できるように script を追加

package.json
"scripts": {
    "test": "vitest"
  },

sum関数のテストコード

Jestと変わらない書き方でテストが実行できた

sum.test.ts
import {expect, test} from "vitest";
import {sum} from "./sum.ts";

test('1 + 2 equals 3', () => {
    expect(sum(1, 2)).toBe(3)
})

普通に実行できそうなので、これ以降はVitestを使うときのポイントに絞って記録していくことにする

kazokmrkazokmr

2.3.10 Errorの評価まで進んだ。Vitestでも参考書通りに進められる。

このセクションで toThrow関数 を使ったエラーメッセージの評価を行っているんだけど、 toThrow("message") だと、substring で検証することに注意。エラーメッセージを全文一致で検証したい場合は、正規関数を使う toThrow(/^message$/) か、エラーオブジェクトを生成する toThrow(new Error("message")) 必要がある

これも Vitest と Jest で同じ仕様

https://vitest.dev/api/expect.html#tothrowerror

https://jestjs.io/ja/docs/expect#tothrowerror

kazokmrkazokmr

「2.3 テスト結果の評価」まで完了。Vitestでも書籍の内容通り書けた。まぁJest互換ってなっていたのでそうだろうけど。

なお、2.3.11 のCallback関数の評価で done を利用した評価方法の説明があるが、Vitestでは非推奨になっており、async/await か Promise を使うようにとのことだった。

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

kazokmrkazokmr

2.4.4 テストを並列で実行

Jest の --runInBand --maxWorkers は、それぞれ --threads=false maxThreads となる模様
かつ、maxThreads の方は CLIでは実行できず vite.config.ts に記載しておく必要があるのと Number型なので CPUの数を"%"では指定できない

https://vitest.dev/config/#threads

2.4.5 テストを並列で実行

Vitestでも maxConcurrency は指定できるが CLIのオプションではなく vite.config.ts に記載する

https://vitest.dev/config/#maxconcurrency

vite.config.ts
import {defineConfig} from "vitest/config";

export default defineConfig({
    test: {
        maxConcurrency: 10
    }
})

サンプルのテストコード自体は変更せずにCLIでテストを実行すれば、並列実行前は100秒、並列実行後は10秒で完了した

並列実行前
~/I/hello-vitest-ts ❯❯❯ vitest --threads=false src/chapter2/group/concurrent.test.ts                                                                                  develop ✱

 DEV  v0.30.1 ./hello-vitest-ts

stdout | src/chapter2/group/concurrent.test.ts > fetchData +0
0
...
stdout | src/chapter2/group/concurrent.test.ts > fetchData 99
99

 ✓ src/chapter2/group/concurrent.test.ts (100) 100206ms

 Test Files  1 passed (1)
      Tests  100 passed (100)
   Start at  06:31:26
   Duration  100.40s (transform 23ms, setup 0ms, collect 16ms, tests 100.21s, environment 0ms, prepare 51ms)


 PASS  Waiting for file changes...
       press h to show help, press q to quit
並列実行後
~/I/hello-vitest-ts ❯❯❯ vitest --threads=false src/chapter2/group/concurrent.test.ts                                                                            ✘ 1 develop ✚ ✱

 DEV  v0.30.1 ./hello-vitest-ts

stdout | src/chapter2/group/concurrent.test.ts > fetchData +0
0
...
stdout | src/chapter2/group/concurrent.test.ts > fetchData 99
99

 ✓ src/chapter2/group/concurrent.test.ts (100) 10037ms

 Test Files  1 passed (1)
      Tests  100 passed (100)
   Start at  06:35:12
   Duration  10.23s (transform 24ms, setup 0ms, collect 15ms, tests 10.04s, environment 0ms, prepare 52ms)


 PASS  Waiting for file changes...
       press h to show help, press q to quit

確認後、maxConcurrency の設定は削除した

kazokmrkazokmr

2.4まで完了。CLIやオプションに違いはあるものの テストコードはサンプルコードのまま進められている

kazokmrkazokmr

2.6 モックを利用したテストについて

jest.fun(), jest.mock() jest.spyOn() は、それぞれ vi.fun(), vi.mock(), vi.spyOn() となるが基本的な挙動は同じ。

気になっているのは 外部モジュールやグローバルオブジェクトの関数のモック化の書き方について

外部モジュールのモック化

書籍では外部モジュール axios をモック化し getの戻り値を次のように定義している
(axios as jest.Mocked<typeof axios>).get.mockResolvedValue(resp);

Vitestでは次のように書ける
(axios as Mocked<typeof axios>).get.mockResolvedValue(resp);

さらに Vitestだと vi.mocked() を使っても同じ結果が得られたのでこちらを使う方がよさそう。だけど元の書き方と同じなのかが不明

vi.mocked(axios.get).mockResolvedValue(resp);

https://vitest.dev/api/vi.html#vi-mocked

モックのリセット

書籍では Date関数のモック化をjest.fn() を利用して次のように宣言している
Date = jest.fn(() => mockDate) as unknown as jest.MockedFunction<typeof Date>;

だがVitestだと同じように書いてもコンパイルエラーになるし、その後のMockプロパティも参照できない。
Date = vi.fn(() => mockDate) as unknown as MockedFunction<typeof Date>;

TS2322: Type 'MockedFunction<DateConstructor>' is not assignable to type 'DateConstructor'.   Type 'string' is not assignable to type 'Date'.

悩んだけど解決方法が見つからなかったのと、ここで確認したいのはDate関数のモック化の方法ではなくモックのリセットの内容だったので、次のように spyOn()fn() の2つでDate関数をモック化した

reset.test.ts
import {beforeEach, describe, expect, Mock, SpyInstance, test, vi} from "vitest";

describe("#reset mocks with vi.fn", () => {
    const targetDate = "2020-12-25";
    const mockDate = new Date("2019-12-25");
    let spyDate: SpyInstance<never, string | Date>;
    let mockFn: Mock<string[], Date>;

    beforeEach(() => {
        // spyOnを使ってglobalオブジェクト(JavaScriptの標準組み込みオブジェクト)のDate関数をMock化する
        spyDate = vi.spyOn(global, "Date").mockImplementation(() => mockDate);
        // Mock関数の実装方法によって、mockRestoreの仕様が変わる
        mockFn = vi.fn().mockImplementation(() => mockDate);
        // mockFn = vi.fn(() => mockDate);
    });

    test("vi.clearAllMocks", () => {
        expect(new Date(targetDate)).toEqual(mockDate);
        expect(spyDate.mock.calls).toHaveLength(1);
        expect(spyDate.mock.calls[0]).toEqual(["2020-12-25"]);
        expect(spyDate.mock.results).toEqual([{type: "return", value: mockDate}]);

        expect(mockFn(targetDate)).toEqual(mockDate);
        expect(mockFn.mock.calls).toHaveLength(1);
        expect(mockFn.mock.calls[0]).toEqual(["2020-12-25"]);
        expect(mockFn.mock.results).toEqual([{type: "return", value: mockDate}]);

        vi.clearAllMocks();

        // ClearするとMockの実行情報(mockプロパティ)がリセットされる
        expect(spyDate.mock.calls).toHaveLength(0);
        expect(spyDate.mock.calls).toEqual([]);
        expect(spyDate.mock.results).toEqual([]);

        expect(mockFn.mock.calls).toHaveLength(0);
        expect(mockFn.mock.calls).toEqual([]);
        expect(mockFn.mock.results).toEqual([]);

        // Mock化したDate関数は維持される
        expect(new Date(targetDate)).toEqual(mockDate);
        expect(mockFn(targetDate)).toEqual(mockDate);
    });

    test("vi.resetAllMocks", () => {
        expect(new Date(targetDate)).toEqual(mockDate);
        expect(spyDate.mock.calls).toHaveLength(1);
        expect(spyDate.mock.calls[0]).toEqual(["2020-12-25"]);
        expect(spyDate.mock.results).toEqual([{type: "return", value: mockDate}]);

        expect(mockFn(targetDate)).toEqual(mockDate);
        expect(mockFn.mock.calls).toHaveLength(1);
        expect(mockFn.mock.calls[0]).toEqual(["2020-12-25"]);
        expect(mockFn.mock.results).toEqual([{type: "return", value: mockDate}]);

        vi.resetAllMocks();

        // mockプロパティは clearと同じくリセットされる
        expect(mockFn.mock.calls).toHaveLength(0);
        expect(mockFn.mock.calls).toEqual([]);
        expect(mockFn.mock.results).toEqual([]);

        expect(mockFn.mock.calls).toHaveLength(0);
        expect(mockFn.mock.calls).toEqual([]);
        expect(mockFn.mock.results).toEqual([]);

        // mockImplementationで実装したMock関数もリセットされる(Date関数自体はMock化はされたまま)
        expect(new Date(targetDate)).toEqual({});
        expect(mockFn(targetDate)).toEqual(undefined);  // vi.fn() の場合は undefinedを返す
    });

    test("vi.restoreAllMocks", () => {

        expect(new Date(targetDate)).toEqual(mockDate);
        expect(spyDate.mock.calls).toHaveLength(1);
        expect(spyDate.mock.calls[0]).toEqual(["2020-12-25"]);
        expect(spyDate.mock.results).toEqual([{type: "return", value: mockDate}]);

        expect(mockFn(targetDate)).toEqual(mockDate);
        expect(mockFn.mock.calls).toHaveLength(1);
        expect(mockFn.mock.calls[0]).toEqual(["2020-12-25"]);
        expect(mockFn.mock.results).toEqual([{type: "return", value: mockDate}]);

        vi.restoreAllMocks();

        // mockプロパティは clearと同じくリセットされる
        expect(spyDate.mock.calls).toHaveLength(0);
        expect(spyDate.mock.calls).toEqual([]);
        expect(spyDate.mock.results).toEqual([]);

        expect(mockFn.mock.calls).toHaveLength(0);
        expect(mockFn.mock.calls).toEqual([]);
        expect(mockFn.mock.results).toEqual([]);

        // vi.spyOn()で定義したMock関数は開放され通常のDate関数に戻る
        expect(new Date(targetDate)).not.toEqual(targetDate);
        expect(new Date(targetDate)).toEqual(new Date(targetDate));

        // vi.fn()で定義したMock関数
        expect(mockFn(targetDate)).toEqual(undefined);      // vi.fun().mockImplementation(impl) で定義するとundefined
        // expect(mockFn(targetDate)).toEqual(mockDate);    // vi.fun(impl)で定義するとMock関数を再実装する
    });
});

vi.fn() でmock化した時の mockResetmockRestore の挙動は Jestと少し違う模様。Jestの場合は mock関数がリセットされ Dateを生成すると空のオブジェクト {} を返すのだが、Vitestの場合は undefinedを返す。さらに Restoreの場合、vi.fn(impl) で宣言したmock関数は定義された処理で再びモック関数が実行される。つまり書籍の場合だと mockDateが返る。

https://vitest.dev/api/mock.html#mockrestore

おまけ

モックのリセットの挙動確認のために 書籍では JavaScriptのグローバルオブジェクトである Date関数で説明されていたが、Vitestだと Date専用の関数(vi.useFakeTimers(), vi.setSystemTime() など)が存在するので 実際にDateをモック化する場合はこちらを採用すると良いと思うし、別のオブジェクトで例示した方がわかりやすかったかもと思った

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

https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects

kazokmrkazokmr

2.7.1 jsdomを利用したテストの変更点

Jestだと jest-environment-jsdom をインストールするが、Vitestは直接 jsdom をインストールする。
実施時点の jsdomの最新は 22.0.0

npm install --seve-dev jsdom @types/jsdom

さらに testEnvrinmentの設定は Jestと同じく設定ファイルかテストコードに アノテーション @vitest-envrionment jsdom をつける

vite.config.ts
export default defineConfig({
    test: {
        environment: "jsdom",
    }
})

https://vitest.dev/config/#environment

尚、書籍には次のような対策から testEnvrionmentを "node" としているが VItestは jsdomを直接インストールしているのでこの不具合は起こらなず "jsdom" を指定できる

今回利用する jest-envrionment-jsdom 29.4.3 が依存する jsdom に問題があり、testEnvrionment に jsdom を指定した場合 TextEncoder もしくは TextDecoder が定義されていないというエラーが発生します。

dispatchEvent() を利用した時のエラーの対応

サンプルコードの通りテストコードを書くと、 button?dispatchEvent(click) を実行すると次のエラーが発生してしまう

SecurityError: localStorage is not available for opaque origins

Jestだと jest.config.ts に testURL: "http://localhost:8080" を追加すれば解消されるが、Vitestには同等のオプションが見当たらなかったので、テストコードで JSDOMインスタンス生成時にbaseURLを渡すようにして解消した。

ui.test.ts
onst baseUrl = "http://localhost:8080";
window = new JSDOM(html, {runScripts: "dangerously", url: baseUrl}).window;

ちなみにエラー自体はVitestだから発生したというよりは jsdom のバージョンかなと考えているが未確認

kazokmrkazokmr

2.7.2 スナップショットテスト

ここでは2点

react-test-renderer の import

サンプルコードでは、 import renderer from "react-test-renderer" となっているが、 import * as renderer from "react-test-renderer" とする

これは react-test-renderer@18.2.0 では default export が設定されておらずエラーになるため

Vitestはcssファイルがimportできるみたい

Jestはデフォルトでcssファイルがインポートできないので、indentity-obj-proxy を入れてCSSモジュールをモック化しているが、Vitestだと何もしなくても classNameの変更を認識してくれた

逆に この辺の css の設定が必要かと思ったけど、特に無くても書籍通りの対応はできた。

https://vitest.dev/config/#css

kazokmrkazokmr

2.7.3 React Testing Library を利用したUI テスト

サンプルコード通りでVitestでも実行できるけど、以下を変えてみた

  1. fireEventuserEvent に変えた
  • userEventfireEvent によるユーザーの操作をよりインタラクティブにしたもの
  1. button要素の取得に getByText ではなく、 getByRole を使った
  • アクセシビリティ(a11y) を考慮すると roleで取得する方がPriorityが高かったため

https://testing-library.com/docs/user-event/intro

https://testing-library.com/docs/queries/about#priority

Button.test.tsx
import {describe, expect, test} from "vitest";
import {Button} from "./Button";
import {render} from "@testing-library/react";
import userEvent from "@testing-library/user-event";

describe("Button", () => {
    test("changes the button text upon clicking the button using React Testing Library", async () => {
        const button = render(<Button/>);
        const user = userEvent.setup();
        await user.click(button.getByRole("button", {name: "ON"}));
        expect(button.getByRole("button", {name: "OFF"})).toBeTruthy();
        await user.click(button.getByRole("button", {name: "OFF"}));
        expect(button.getByRole("button", {name: "ON"})).toBeTruthy();
    });
});
kazokmrkazokmr

2.7.4 Storybookの活用

ここの内容はVitestと直接関係ないが、サンプルに対して変えたところを挙げておく

Storybook7.x で試した

今日時点で最新の 7.0.9 を使った

npm storybook@latest init で実行すると、現在のプロジェクトの構成を見て TypeScriptを採用し、フレームワークはReact、そしてビルダーを Viteで初期設定してくれた

TypeScriptになったことで、.storybook ディレクトリに作られるファイル名が main.tspreview.ts で作られた

また interactionsDebuggerも正式版になったのか、main.tsに追加しなくても利用できるようになっていた。

なので、Storybook の設定ファイルは初期値から何も変えなかった。

Storyの書き方

CSF3.0は同じだけど、Storybookの公式ドキュメントに従って少し変えてみた

Button.stories.tsx
import {Button} from "./Button";
import {Meta, StoryObj} from "@storybook/react";
import {userEvent, within} from "@storybook/testing-library";

const meta = {
    component: Button,
} satisfies Meta<typeof Button>;

export default meta;
type Story = StoryObj<typeof meta>;

export const Primary = {} satisfies Story;

export const Secondary = {
    args: {primary: false}
} satisfies Story;

export const ClickButton = {
    play: async ({canvasElement}) => {
        const canvas = within(canvasElement);
        const button = canvas.getByRole("button", {name: /(ON|OFF)/});
        await userEvent.click(button);
    }
} satisfies Story;

https://storybook.js.org/docs/react/get-started/whats-a-story

https://storybook.js.org/docs/react/configure/typescript

https://storybook.js.org/docs/react/writing-stories/play-function

以上で 「2.7 UIテスト」の動作確認も完了

kazokmrkazokmr

MetaオブジェクトやStoryオブジェクトを satisfies と 型注釈 で宣言した場合の違いはイマイチ理解できていない。 (Storyファイルにおける satisfies を使うことの恩恵が分からず、型注釈でも良いかなと感じている。)

kazokmrkazokmr

2.8.5 Selenium を利用したE2Eテスト

テストのタイムアウト設定について サンプルコードでは jest.setTimeout(20000) と定義しているがVitestではこれに該当する設定がなさそう。

代替案としては、describetest の 第3引数で設定するか、vite.config.ts の testTimeoutまたは 実行コマンドオプション --test-timeout=20000 をセットする方法が考えられる。

ちなみに 自分のPCでサンプルコードを実装するだけなら デフォルト値の5秒以内に各テストが完了するので延長する必要はなかった。

kazokmrkazokmr

2.8.6 Puppeteer を利用した E2Eテスト

Vitestとは関係なくサンプルコードの中で、検索ボックス要素を取得するための selector に input を 使っているが ここは textarea だったので、textarea じゃないと選択できずテストが失敗した

await page.type("textarea[name='q']", "puppeteer");

kazokmrkazokmr

2.8.7 Playwright を利用した E2Eテスト

特に問題なくVitestでも動いた。

それにしても Playwrightは面白い。JestとかVitest使わなくてもいけるし、Javaでも書けるし

kazokmrkazokmr

おまけ

VitestのAPIは importで宣言する必要がある。

import {afterAll, beforeAll, describe, expect, test} from "vitest";

これを Jestのようにglobalに利用したい場合は vite.config.ts で test.globalstrue にすると良いらしい

https://vitest.dev/config/#globals

kazokmrkazokmr

おまけその2: jest-dom

書籍やサンプルコードではマッチャー関数は Jest / Vitest が持っている関数だけを使っていたがフロントエンド開発向けだと DOMの状態で評価できるライブラリ jest-dom を使うと便利

https://github.com/testing-library/jest-dom

Vitestでも使えるみたいだけど、./src/tests/setup.ts ファイルを作り、次のように jest-dom のマッチャーが利用できるように拡張する設定が必要みたい

setup.ts
import { expect } from 'vitest';
import matchers from '@testing-library/jest-dom/matchers';

expect.extend(matchers);

setup.ts は vite.config.ts にも記述する

vite.config.ts
export default defineConfig({
    test: {
        environment: "jsdom",
        setupFiles: './src/tests/setup.ts'
    }
})
kazokmrkazokmr

おまけ その3: SveltKitでVitestを使う

以下は Svelte用のtesting-libraryの導入・セットアップ手順だけど一通り記載されているのでリンクしておく。

https://testing-library.com/docs/svelte-testing-library/intro

https://testing-library.com/docs/svelte-testing-library/setup

なお、Svelteをインストールするときに オプションで Vitest や Playwright も一緒にセットアップできる。

https://kit.svelte.dev/docs/creating-a-project

kazokmrkazokmr

2章のサンプルコードは大きな変更なく、全てVitestで書けることが確認できた。

その上、Jestだと追加のパッケージや制約がある点もVitestの方はクリアしていたりするため、今後はVitestを使っていきたいと思えた。

kazokmrkazokmr

4.3.3 テスト結果の保存

CircleCIにテスト結果をアップロードするため、テストの実行結果をJUnitフォーマットのXMLファイルで出力している。Jest(書籍)では jest-junit パッケージをインストールしているが、Vitestは vitest.config.ts に 設定するだけで JUnitフォーマットのXMLファイルが出力できた。

vite.config.ts
import { defineConfig } from "vitest/config";

export default defineConfig({
  test: {
    environment: "jsdom",
    css: true,
    coverage: {
      provider: "c8",
      reporter: ["html"],
    },
    reporters: [
      "default",
      "junit",
    ],
    outputFile: "./reports/vitest/vitest-report.xml",
  },
});

https://vitest.dev/config/#reporters

https://vitest.dev/config/#outputfile

kazokmrkazokmr

SvelteKitで @testing-library/jest-dom を利用した場合に問題が発生した。これはVitestと組み合わせることで発生する問題なのでリンクしておく。

簡単にまとめるt

  1. ./vitest.setup.ts ファイルを用意し、 import @testing-libray/jest-dom; でテスト実行時にjest-domをインポートするようにする。
  • ファイル名やパスは変更可能。またファイルを用意せずにjest-domを使うテストファイルで直接インポートする方法でも良い。
  1. ./vite.config.ts ファイルで テスト設定を2つ追加する
  • globals:true として VitestのGlobal APIの明示的なImportを不要にする
  • setupFilesプロパティに 1で作成したセットアップファイルを追加する

https://zenn.dev/link/comments/8a7c2ecbf360e8

このスクラップは2023/05/07にクローズされました