こんにちは!フロントエンドの世界は常に進化していて、新しい技術やツールが次々と現れるよね。そんな中、テストフレームワークの選択は、ウェブエンジニアの旅路における重要な決断だ。今回は、JavaScript/TypeScriptの開発において、新たな可能性を秘めたテストライブラリ「Vitest」をご紹介するよ。
Jestに似ているけど、さらにいくつかの特長を備えたVitest。この記事では、Vitestの導入から基本的な使い方、さらには発展的な機能までを掘り下げていくよ。これからVitestの航海に出る君たちに、少しでも役立つ情報を提供できればと思ってる。
本記事は、Next.jsとの連携やViteの恩恵を受けるVitestの素晴らしさを体験したいと考えているウェブエンジニアのために特別に用意した。船出の準備はいいかい?それでは、Vitestの世界への航海を始めよう!
↑はうちのAIくんに考えてもらいました。ちょっと張り切りすぎ。
仕事で使う機会があったので、JavaScript/TypeScriptのテストフレームワーク(ライブラリ)であるVitestに入門した。この記事では、既に他言語でのテスト経験があるウェブエンジニアのためのVitest導入の手順、基本的・発展的機能の紹介、Vitestに得た感触などをメモするものだ。筆者は最近Next.jsによるフロントエンド開発を本格的にスタートさせた、キャリア的にはバックエンドがメインのエンジニアだ。
この記事はご覧のライブラリバージョンでお送りします。
- Vite 0.34.6
- Next 13.5.6
Vitestとは何か
Vitestは、JavaScript(JS)/TypeScript(TS)のためのテストライブラリだ。このライブラリを使ってテストを構成することでnpmなどを経由してテストを実行できる。
同様のテストライブラリとしてJestがあるが、VitestはJestとほぼ互換であることを掲げているため、Jestに既に慣れ親しんだユーザにとってはVitestの導入は容易であろう。
Jest Compatible Expect, snapshot, coverage, and more - migrating from Jest is straightforward.
Vitestのメリット
VitestはJestよりも後発でありながら、エンジニアに支持されうるいくつかの特長を備えている。
- ゼロコンフィグでESM(ECMAScript Module)に対応する
- いくつかのフレームワークは内部的にESMを呼び出していたりする。このような場合、Jestを利用するときは煩雑な設定が必要になるが、Vitestでは最初からESMに対応しており、Viteを利用して適切なバンドルが実行される。
- 高速
- VitestはViteに依存する。Viteがバックエンドにあるおかげで、高速実行といったViteが持つパフォーマンス上の優位を手軽に得られる。
- CIにおいてはテスト速度は重要である。CIの速度はそのままデリバリースピードに影響する。
- Hot Module Reload(HMR)を活用した、watchモードで動作するランナー
Vitestのデメリット
Vitestはその性質上、Viteを依存性として持つため、依存性を極限まで切り詰めたい場合には不適かもしれない。ただし最終的な成果物にバンドルされるファイルには当然このような依存性は含まれない。
また、歴史的にはJestのほうが長く使われてきており、事例や周辺知識に不安がある人もいるかもしれない。しかしVitestはJestとほぼ互換であり、Jestのテクニックがほぼそのまま通用するだろう。
入門: Vitestの導入
今回は典型的な例としてNext.jsプロジェクトにVitestを導入する手順を説明する(普通のReactプロジェクトと大差ないが)。既に導入が済んでいる場合はこのセクションを飛ばしてよい。
また、今回はライブラリマネージャとしてpnpmを使っているが、各環境によって適切なコマンドラインに読み替えてほしい。
パッケージインストール
Vitestを導入するには、vitest
パッケージに加えて、DOMやReact Componentをテストするための追加ライブラリをインストールする:
% npm install -D vitest @vitejs/plugin-react @testing-library/jest-dom @testing-library/react @testing-library/user-event jsdom typescript
package.json
にテスト実行用スクリプトを記載する
次に、pnpm run test
といったコマンドでテストが実行できるようにpackage.json
に追記する:
{ "scripts": { + "test": "vitest --watch --ui --coverage.enabled=true", + "test:ci": "vitest --coverage.enabled=true --ci", } }
Vitestの初期設定をvitest.config.js
に設定する
Vitestはゼロコンフィグで利用可能だが、ここでは特定のライブラリを全テストファイルでimportしたいのでそのための設定を行いたい。Vitestの設定はプロジェクトルートのvitest.config.js
に記述する。
/// <reference types="vitest" /> import react from '@vitejs/plugin-react' import { defineConfig } from 'vitest/config' export default defineConfig({ plugins: [react()], test: { globals: true, environment: 'jsdom', setupFiles: './test/setup.ts', // 場所はどこでもいい。テストと揃えればよい }, resolve: { alias: { '@': __dirname + '/src', }, }, })
今回はDOMのためのmatcher(後述)を供給してくれる@testing-library/jest-dom
を全テストファイルで利用したいため、test/setup.ts
を読み込ませている。
実際のtest/setup.ts
には以下のように記述する:
import '@testing-library/jest-dom';
@testing-library/jest-dom
はimportするだけで効力を発揮するため、これだけでよい。
次のセクションでは、実際にVitestが提供するDSLを用いてテストを構成する。
基礎: Vitestでテストを書く
このセクションでは、Vitestが提供するDSLを利用し、基本的なmatcherによるテストを構成する方法を説明する。
VitestはJest同様、describe
やtest
といったDSLを提供することで構造的にテストを書ける。Jestに慣れ親しんでいる場合はほぼ同じだろう。
Vitestのファイル構成
Vitestでは、テストコードをどこに配置するかはかなり柔軟に決めることができる。デフォルトでは**/*.{test,spec}.?(c|m)[jt]s?(x)
にマッチするファイルがテストの対象になる(Configuring Vitest | Vitest を参照)。今回はテストファイルを./test/
以下に配置し、ファイル名としてfoobar.spec.tsx
のように書くこととした。このシチュエーションであれば特に追加の設定は不要だ。
VitestのDSL
最初のテストを書いてみよう。./test/foobar.spec.ts
に、以下のように記述する:
import { describe, expect, it, vitest } from 'vitest'; describe("Number 42", () => { it("is 42", () => { expect(42).toBe(42); }); });
Vitestでは、テストファイル中にdescribe
でテストをグルーピングし、it
で各テストを記述していく。it
の中では最終的にexpect().toFooBar
という形式で値のテストを行う。この.toFooBar
の部分をmatcherと呼ぶ。matcherはプラグインにより追加できる。
これらのdescribe
、it
、expect
、そしてmatcherはVitestが提供するDSLだ。ちなみに、it
はtest
のエイリアスなので、慣れているほうで書いてよい。
Vitestを実行する
Vitestを実行するには、pnpm run test
を実行する。さきほどpackage.json
でtest
するとvitest --watch --ui
が動作するように設定していたため、自動的にブラウザが起動し、ウォッチモードに入る。
ブラウザでテスト状況が確認でき、落ちているテストのソース部分を見られるのがとても便利だ。
また、pnpm run test:ci
を実行するとCIモードでテストが実行される。このモードでは、スナップショットテスト(後述)を行った場合に自動的にスナップショットを更新せず、テストを落とす。
これでVitestの基本的な構成は抑えられたはずだ。次のセクションでは、Vitestが持つさまざまなmatcherを用いて目的のテストを実行する。
基本: Vitestのmatcherに習熟する
Vitestでは、他言語と同様にmatcherを用いて値の比較を行う。このセクションでは、そのうち基本的なものを紹介する。
基本的な値の比較
Vitestは以下のようなmatcherを用意している(これはほんの一部である)。
import { describe, expect, it, vitest } from 'vitest'; describe("matcher", () => { it("等価性比較できる", () => { // toBeはObject.isを利用する expect(42).toBe(42); expect("foo").toBe("foo"); // XXX: floating pointに対してtoBeを使うのはバグのもとなので避ける expect(3.14).toBe(3.14); }); it("notで反転できる", () => { expect(true).not.toBe(false); }); it("オブジェクトの等価性を比較できる", () => { const d1 = { x: 42, y: 10, }; const d2 = { x: 42, y: 10, }; expect(d1).not.toBe(d2); expect(d1).toEqual(d2); }); it("大小比較できる", () => { expect(42).toBeGreaterThanOrEqual(42); expect(42).toBeGreaterThan(41); expect(42).toBeLessThan(43) expect(42).toBeLessThanOrEqual(42) }); it("サイズを比較できる", () => { expect("foo").toHaveLength(3); expect([1, 2, 3, 4, 5]).toHaveLength(5); }); it("truthy/falthyを検査できる", () => { expect([]).toBeTruthy(); expect("").toBeFalsy(); }); it("Regexにマッチするか検査できる", () => { expect("panamabanana").toMatch(/^(.a)+$/) expect("panamabanana").toMatch("banana") }); it("非同期処理の結果を検査できる", async ({ expect }) => { const calcAnswer = async () => { return Promise.resolve(42) }; expect(calcAnswer()).resolves.toBe(42); }); it("throwを検査できる", () => { const thrower = () => { throw "boom" }; expect(thrower).toThrow("boom"); }); });
テストを実行をすると各テストごとの結果が表示された。
Reactコンポーネントの比較
Vitestでは、いくつかのプラグインを利用することでReactコンポーネントが意図した通りにレンダリングされているかどうかなどをテストできる。
まず例として、以下のようなコンポーネントがあると仮定する:
export default function Message(props: {message: string}) { <span className="warning">{props.message}</span> }
テスト中では、「ドキュメント」にコンポーネントをレンダーし、その様子を観察するというスタイルでテストを行う。コンポーネントをレンダーするには、@testing-library/react
が提供するrender
を利用する:
import { fireEvent, render, screen } from "@testing-library/react"; import { describe, expect, it, vitest } from 'vitest'; describe("Message", () => { it("can be rendered", () => { render(<Message message="Hello, Vitest!" />) }); });
@testing-library/react
は、以下のようなAPIを提供する:
render
- React コンポーネントをレンダーする
- デフォルトでは
body
の下にdiv
が作られ、そこにレンダーされる
screen
- Reactコンポーネントがレンダーされたコンテナから要素を探したりするためのオブジェクト
fireEvent
- クリックイベントなどのイベントを発火させるヘルパー
- 本格的にイベントを含むテストをやりたい場合は、よりリッチな
@testing-library/user-event
を使うべきとされている
screen
は以下のようなメソッドを提供し、要素を探す手助けをする:
getByRole
getByLabelText
getByPlaceholderText
getByText
getByDisplayValue
またgetBy
のバリエーションとしてqueryBy
、getAllBy
などが用意されている(getBy
シリーズはマッチしない場合にthrowするが、queryBy
シリーズはnullを返すにとどまる)
コンポーネントがレンダーされているか、想定通りになっているかを検査するには、screen
を活用する。例えばscreen.findByText
を使うとテキストを使って要素などを検索できる。これに限らず検索系APIは非同期に結果を返すものが多いため、async
/await
を使って処理する。
import { fireEvent, render, screen } from "@testing-library/react"; import { describe, expect, it, vitest } from 'vitest'; describe("Message", () => { it("can be rendered", async ({ expect }) => { render(<Message message="Hello, Vitest!" />); const found = await screen.findByText("Hello, Vitest!"); expect(found).toBeInTheDocument(); expect(found).toHaveClass("warning"); }); });
toBeInTheDocument
は@testing-library/jest-dom
が供給しているmatcherで、冒頭のsetup.ts
であらかじめimport済みである。
jest-dom
が供給するmatcherのうちよく使いそうなものは以下の通り:
toBeInTheDocument
- それがドキュメント上にあることを検証する
toHaveClass
- 要素が特定のクラスを持っていることを検証する
- 同様に
toHaveAttribute
、toHaveStyle
、toHaveDisplayValue
、toHaveTextContent
などがある
toBeVisible
- ブラウザ上からその要素が可視であることを検証する
opacity
やdisplay
属性などを考慮してくれる
Next.js特有の処理
Next.jsの場合はフレームワークであることから複数のコンポーネントが協調しており、そのままコンポーネントのテストができない場合がある。
自分の場合はルーターまわりでエラーが発生したので、関連箇所を切り離す処理を行った。
// routerまわりでエラーが出ることがあるので、最初にmockを用意する。単純なコンポーネントであれば必要ないはず vitest.mock("next/navigation", () => ({ useRouter() { return { prefetch: () => null }; } }));
発展: 便利な機能
このセクションでは、知っておくと便利な発展的な話題を提供する。
todo
describe
やit
には.todo
メソッドが生えており、これを呼ぶとテストをスキップさせられる。
describe("Message", () => { // このテストをスキップする it.todo("can be rendered", async ({ expect }) => { render(<Message message="Hello, Vitest!" />); const found = await screen.findByText("Hello, Vitest!"); expect(found).toBeInTheDocument(); expect(found).toHaveClass("warning"); }); });
beforeEach
/ afterEach
他のテストフレームワーク同様に、テストの前後に特定の処理を行う機能がVitestには用意されている。
import { beforeEach } from 'vitest' beforeEach(async () => { // Clear mocks and add some testing data after before each test run await stopMocking() await addUser({ name: 'John' }) })
beforeEach
はit
単位で実行される。beforeAll
はテストファイル単位で実行される。同様にしてafterEach
/ afterAll
も存在する。モックのセットアップやテストデータのクリーンアップなどに使うと良いだろう。
モック
Vitestはモックも提供している。
こちらは公式にチートシートが用意されているので、これを見ると良いだろう。
カバレッジ
vitest --watch --ui --coverage.enabled=true
のように設定しておくと、全自動で勝手にカバレッジを取ってくれるので、とりあえず有効にしておくと良い。
Snapshot testing
Vitestではスナップショットを使ったテストが可能だ。スナップショットテストでは、テスト対象の値が変化していないことを保証するためのテクニックだ。一般のテストでは値が一定の条件を満たすことを検証するが、スナップショットテストではそれが前回の正常なテストから変化していないことも検証する。
スナップショットテストは、勝手に内容が変化しては困る対象、例えばコンポーネントのレンダリング結果やAPIのレスポンスなどに対して適用される。
Vitestである値がスナップショットと合致しているかどうかを検証するには、toMatchSnapshot
を利用する:
import { fireEvent, render, screen } from "@testing-library/react"; import { describe, expect, it, vitest } from 'vitest'; describe("Message", () => { it("can be rendered", async ({ expect }) => { render(<Message message="Hello, Vitest!" />); const found = await screen.findByText("Hello, Vitest!"); expect(found).toBeInTheDocument(); expect(found).toHaveClass("warning"); expect(found).toMatchSnapshot(); }); });
スナップショットが存在しない場合、Vitestはテストファイルと同じ階層にディレクトリを作成して新規にスナップショットを保存する。
テスト結果がスナップショットと異なる場合、Vitestはテストを失敗させる。
スナップショットを更新させるにはテスト結果の画面でu
を押下する。
ちなみにスナップショットはWeb UIからも更新を指示できるので、こちらのほうが便利だろう。
pnpm test:ci
などでVitestがCIモードで実行されている場合は、スナップショットの更新は行なわれず、ユーザへの指示だけが表示される。
CIへの導入
他のテストツール同様、VitestはGitHub ActionsといったCI/CD基盤で利用できる。特に役立ちそうな設定は以下の通り:
- キャッシュ
- 設定として
cache.dir
にキャッシュ先ディレクトリを指定すると、Vitestはキャッシュできるファイルをそこに保存するようになる(Vitestはデフォルトでキャッシュする)。 - GitHub Actionsの
cache
アクションなどを利用してこれをキャッシュするとテストの高速化が期待できる。
- 設定として
- カバレッジ
- カバレッジをGitHub Actionsに報告するactionが存在する。
- https://github.com/marketplace/actions/vitest-coverage-report
まとめ
この記事では、JS/TS向けテストライブラリであるVitestについての簡潔な説明から始め、テストの基礎から発展的な話題までを一直線に解説した。より専門的・詳細な話題は公式のドキュメントを検索することをおすすめする。