Lambdaカクテル

京都在住Webエンジニアの日記です

Invite link for Scalaわいわいランド

ウェブ開発の新しい航海者たちへ: Vitestで航路を切り開く

こんにちは!フロントエンドの世界は常に進化していて、新しい技術やツールが次々と現れるよね。そんな中、テストフレームワークの選択は、ウェブエンジニアの旅路における重要な決断だ。今回は、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.dev

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同様、describetestといった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はプラグインにより追加できる。

これらのdescribeitexpect、そしてmatcherはVitestが提供するDSLだ。ちなみに、ittestのエイリアスなので、慣れているほうで書いてよい。

Vitestを実行する

Vitestを実行するには、pnpm run testを実行する。さきほどpackage.jsontestするとvitest --watch --uiが動作するように設定していたため、自動的にブラウザが起動し、ウォッチモードに入る。

使いやすいWeb 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のバリエーションとしてqueryBygetAllByなどが用意されている(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
    • 要素が特定のクラスを持っていることを検証する
    • 同様にtoHaveAttributetoHaveStyletoHaveDisplayValuetoHaveTextContentなどがある
  • toBeVisible
    • ブラウザ上からその要素が可視であることを検証する
    • opacitydisplay属性などを考慮してくれる

Next.js特有の処理

Next.jsの場合はフレームワークであることから複数のコンポーネントが協調しており、そのままコンポーネントのテストができない場合がある。

自分の場合はルーターまわりでエラーが発生したので、関連箇所を切り離す処理を行った。

// routerまわりでエラーが出ることがあるので、最初にmockを用意する。単純なコンポーネントであれば必要ないはず
vitest.mock("next/navigation", () => ({
  useRouter() {
    return {
      prefetch: () => null
    };
  }
}));

発展: 便利な機能

このセクションでは、知っておくと便利な発展的な話題を提供する。

todo

describeitには.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' })
})

beforeEachit単位で実行される。beforeAllはテストファイル単位で実行される。同様にしてafterEach / afterAllも存在する。モックのセットアップやテストデータのクリーンアップなどに使うと良いだろう。

モック

Vitestはモックも提供している。

こちらは公式にチートシートが用意されているので、これを見ると良いだろう。

vitest.dev

カバレッジ

vitest --watch --ui --coverage.enabled=trueのように設定しておくと、全自動で勝手にカバレッジを取ってくれるので、とりあえず有効にしておくと良い。

Web UIの場合はここを押す

Web UIからもカバレッジを確認できる

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はテストを失敗させる。

差分までUIに表示してくれる

スナップショットを更新させるにはテスト結果の画面でuを押下する。 ちなみにスナップショットはWeb UIからも更新を指示できるので、こちらのほうが便利だろう。

更新ボタンまでWeb UIに生えている親切設計

pnpm test:ciなどでVitestがCIモードで実行されている場合は、スナップショットの更新は行なわれず、ユーザへの指示だけが表示される。

CIへの導入

他のテストツール同様、VitestはGitHub ActionsといったCI/CD基盤で利用できる。特に役立ちそうな設定は以下の通り:

  • キャッシュ
    • 設定としてcache.dirにキャッシュ先ディレクトリを指定すると、Vitestはキャッシュできるファイルをそこに保存するようになる(Vitestはデフォルトでキャッシュする)。
    • GitHub Actionsのcacheアクションなどを利用してこれをキャッシュするとテストの高速化が期待できる。
  • カバレッジ

まとめ

この記事では、JS/TS向けテストライブラリであるVitestについての簡潔な説明から始め、テストの基礎から発展的な話題までを一直線に解説した。より専門的・詳細な話題は公式のドキュメントを検索することをおすすめする。

★記事をRTしてもらえると喜びます
Webアプリケーション開発関連の記事を投稿しています.読者になってみませんか?