Jest本をVitestで試してみる
目的
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
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 の修正
engines
と packageManager
プロパティを追加した。(個人的な好み)
開始時点の package.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
プロジェクト作成時点の設定をベースに始めてみる。本の記載内容とは異なるが進めながら適宜修正していく
{
"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の設定確認はスキップした
初めてのテスト
Vitestのセットアップ
Vitest の Getting Started を参考に vite.config.ts
を用意して、Config の Propertyに test
を追加しただけ
import {defineConfig} from "vitest/config";
export default defineConfig({
test: {}
})
Scriptの追加
npm run test
で vitest を実行できるように script を追加
"scripts": {
"test": "vitest"
},
sum関数のテストコード
Jestと変わらない書き方でテストが実行できた
import {expect, test} from "vitest";
import {sum} from "./sum.ts";
test('1 + 2 equals 3', () => {
expect(sum(1, 2)).toBe(3)
})
普通に実行できそうなので、これ以降はVitestを使うときのポイントに絞って記録していくことにする
VitestのAPI
2.3.10 Errorの評価まで進んだ。Vitestでも参考書通りに進められる。
このセクションで toThrow関数
を使ったエラーメッセージの評価を行っているんだけど、 toThrow("message")
だと、substring で検証することに注意。エラーメッセージを全文一致で検証したい場合は、正規関数を使う toThrow(/^message$/)
か、エラーオブジェクトを生成する toThrow(new Error("message"))
必要がある
これも Vitest と Jest で同じ仕様
「2.3 テスト結果の評価」まで完了。Vitestでも書籍の内容通り書けた。まぁJest互換ってなっていたのでそうだろうけど。
なお、2.3.11 のCallback関数の評価で done
を利用した評価方法の説明があるが、Vitestでは非推奨になっており、async/await か Promise を使うようにとのことだった。
2.4.4 テストを並列で実行
Jest の --runInBand
--maxWorkers
は、それぞれ --threads=false
maxThreads
となる模様
かつ、maxThreads
の方は CLIでは実行できず vite.config.ts に記載しておく必要があるのと Number型なので CPUの数を"%"では指定できない
2.4.5 テストを並列で実行
Vitestでも maxConcurrency
は指定できるが CLIのオプションではなく 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
の設定は削除した
2.4まで完了。CLIやオプションに違いはあるものの テストコードはサンプルコードのまま進められている
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);
モックのリセット
書籍では 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関数をモック化した
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化した時の mockReset
と mockRestore
の挙動は Jestと少し違う模様。Jestの場合は mock関数がリセットされ Dateを生成すると空のオブジェクト {}
を返すのだが、Vitestの場合は undefinedを返す。さらに Restoreの場合、vi.fn(impl)
で宣言したmock関数は定義された処理で再びモック関数が実行される。つまり書籍の場合だと mockDateが返る。
おまけ
モックのリセットの挙動確認のために 書籍では JavaScriptのグローバルオブジェクトである Date関数で説明されていたが、Vitestだと Date専用の関数(vi.useFakeTimers()
, vi.setSystemTime()
など)が存在するので 実際にDateをモック化する場合はこちらを採用すると良いと思うし、別のオブジェクトで例示した方がわかりやすかったかもと思った
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
をつける
export default defineConfig({
test: {
environment: "jsdom",
}
})
尚、書籍には次のような対策から 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を渡すようにして解消した。
onst baseUrl = "http://localhost:8080";
window = new JSDOM(html, {runScripts: "dangerously", url: baseUrl}).window;
ちなみにエラー自体はVitestだから発生したというよりは jsdom のバージョンかなと考えているが未確認
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 の設定が必要かと思ったけど、特に無くても書籍通りの対応はできた。
2.7.3 React Testing Library を利用したUI テスト
サンプルコード通りでVitestでも実行できるけど、以下を変えてみた
-
fireEvent
をuserEvent
に変えた
-
userEvent
はfireEvent
によるユーザーの操作をよりインタラクティブにしたもの
- button要素の取得に
getByText
ではなく、getByRole
を使った
- アクセシビリティ(a11y) を考慮すると roleで取得する方がPriorityが高かったため
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();
});
});
2.7.4 Storybookの活用
ここの内容はVitestと直接関係ないが、サンプルに対して変えたところを挙げておく
Storybook7.x で試した
今日時点で最新の 7.0.9 を使った
npm storybook@latest init
で実行すると、現在のプロジェクトの構成を見て TypeScriptを採用し、フレームワークはReact、そしてビルダーを Viteで初期設定してくれた
TypeScriptになったことで、.storybook
ディレクトリに作られるファイル名が main.ts
と preview.ts
で作られた
また interactionsDebuggerも正式版になったのか、main.tsに追加しなくても利用できるようになっていた。
なので、Storybook の設定ファイルは初期値から何も変えなかった。
Storyの書き方
CSF3.0は同じだけど、Storybookの公式ドキュメントに従って少し変えてみた
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;
以上で 「2.7 UIテスト」の動作確認も完了
MetaオブジェクトやStoryオブジェクトを satisfies と 型注釈 で宣言した場合の違いはイマイチ理解できていない。 (Storyファイルにおける satisfies を使うことの恩恵が分からず、型注釈でも良いかなと感じている。)
2.8.5 Selenium を利用したE2Eテスト
テストのタイムアウト設定について サンプルコードでは jest.setTimeout(20000)
と定義しているがVitestではこれに該当する設定がなさそう。
代替案としては、describe
か test
の 第3引数で設定するか、vite.config.ts の testTimeout
または 実行コマンドオプション --test-timeout=20000
をセットする方法が考えられる。
ちなみに 自分のPCでサンプルコードを実装するだけなら デフォルト値の5秒以内に各テストが完了するので延長する必要はなかった。
2.8.6 Puppeteer を利用した E2Eテスト
Vitestとは関係なくサンプルコードの中で、検索ボックス要素を取得するための selector に input
を 使っているが ここは textarea だったので、textarea
じゃないと選択できずテストが失敗した
await page.type("textarea[name='q']", "puppeteer");
2.8.7 Playwright を利用した E2Eテスト
特に問題なくVitestでも動いた。
それにしても Playwrightは面白い。JestとかVitest使わなくてもいけるし、Javaでも書けるし
おまけ
VitestのAPIは importで宣言する必要がある。
import {afterAll, beforeAll, describe, expect, test} from "vitest";
これを Jestのようにglobalに利用したい場合は vite.config.ts で test.globals
を true
にすると良いらしい
おまけその2: jest-dom
書籍やサンプルコードではマッチャー関数は Jest / Vitest が持っている関数だけを使っていたがフロントエンド開発向けだと DOMの状態で評価できるライブラリ jest-dom
を使うと便利
Vitestでも使えるみたいだけど、./src/tests/setup.ts
ファイルを作り、次のように jest-dom のマッチャーが利用できるように拡張する設定が必要みたい
import { expect } from 'vitest';
import matchers from '@testing-library/jest-dom/matchers';
expect.extend(matchers);
setup.ts は vite.config.ts にも記述する
export default defineConfig({
test: {
environment: "jsdom",
setupFiles: './src/tests/setup.ts'
}
})
おまけ その3: SveltKitでVitestを使う
以下は Svelte用のtesting-libraryの導入・セットアップ手順だけど一通り記載されているのでリンクしておく。
なお、Svelteをインストールするときに オプションで Vitest や Playwright も一緒にセットアップできる。
2章のサンプルコードは大きな変更なく、全てVitestで書けることが確認できた。
その上、Jestだと追加のパッケージや制約がある点もVitestの方はクリアしていたりするため、今後はVitestを使っていきたいと思えた。
4.3.3 テスト結果の保存
CircleCIにテスト結果をアップロードするため、テストの実行結果をJUnitフォーマットのXMLファイルで出力している。Jest(書籍)では jest-junit パッケージをインストールしているが、Vitestは vitest.config.ts に 設定するだけで JUnitフォーマットのXMLファイルが出力できた。
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",
},
});
SvelteKitで @testing-library/jest-dom を利用した場合に問題が発生した。これはVitestと組み合わせることで発生する問題なのでリンクしておく。
簡単にまとめるt
-
./vitest.setup.ts
ファイルを用意し、import @testing-libray/jest-dom;
でテスト実行時にjest-domをインポートするようにする。
- ファイル名やパスは変更可能。またファイルを用意せずにjest-domを使うテストファイルで直接インポートする方法でも良い。
-
./vite.config.ts
ファイルで テスト設定を2つ追加する
-
globals:true
として VitestのGlobal APIの明示的なImportを不要にする -
setupFiles
プロパティに 1で作成したセットアップファイルを追加する