フロントエンド開発でテストを書こうとすると、フレームワーク選定の段階で迷ってしまう方も多いのではないでしょうか。Jestは長く定番でしたが、Viteベースのプロジェクトではトランスパイル設定の二重管理が煩雑になりがちです。そんな課題を解消するのが、Vite公式チームが開発するテストフレームワーク「Vitest」です。本記事では、Vitestの基本概念から実践的な使い方までを整理してお伝えします。
Vitestとは何か──Jestとの違いを理解する
Vitestは、ビルドツールViteのエコシステム上に構築されたテストフレームワークです。2022年に登場して以降、急速にシェアを拡大し、State of JS 2024の調査でもテストツール部門で高い満足度を記録しています。
最大の特徴は、Viteの設定をそのまま共有できる点です。Jestを使う場合、babel.config.jsやts-jestなど別途トランスパイル設定が必要でしたが、Vitestならvite.config.tsの設定がテストにもそのまま適用されます。エイリアスやプラグインの二重管理から解放されるのは、実際に移行してみると想像以上に快適でした。
APIはJest互換を意識して設計されており、describe、it、expectといったおなじみの関数がそのまま使えます。ただし完全互換ではなく、一部のマッチャーや設定に差異がある点は押さえておく必要があります。ESMをネイティブサポートしているため、import/export構文がそのまま動作するのも、モダンなプロジェクトでは大きな利点です。
パフォーマンス面の違い
JestとVitestの体感的な速度差は、プロジェクト規模が大きくなるほど顕著になります。Jestはテストファイルごとにトランスパイルを行うため、TypeScriptやJSXを含むファイルが増えるにつれて起動時間が長くなりがちです。一方Vitestは、Viteのオンデマンドトランスパイルとモジュールキャッシュを活用するため、初回起動こそ差が小さいものの、ウォッチモードでの再実行速度に明確なアドバンテージがあります。
あるプロジェクトでJestからVitestに切り替えた際、テストスイート全体の実行時間が約40%短縮されたことがありました。特にパスエイリアス(@/componentsのような記法)を多用しているプロジェクトでは、Jest側でmoduleNameMapperを設定する手間が不要になるだけでなく、トランスパイルのオーバーヘッドも軽減されます。
Jest互換APIの具体的な差異
「Jest互換」とはいえ、移行時に把握しておきたい差異がいくつか存在します。代表的なものを挙げておきます。
jest.fn()→vi.fn():名前空間がjestからviに変わります。jest.mockもvi.mockに読み替えが必要ですdoneコールバック:Jestでは非同期テストにdoneコールバックを使う古い書き方が残っていますが、Vitestではasync/awaitの使用が推奨されます- スナップショットのシリアライザ:Jestの
snapshotSerializers設定はVitestでも利用可能ですが、設定の記述場所がvite.config.tsのtestプロパティ配下に変わります jest.setTimeout:Vitestではvi.setConfig({ testTimeout: 10000 })またはテスト単位で{ timeout: 10000 }オプションを使います
大半のテストコードはほぼそのまま動作しますが、上記のような細かな差異を事前に把握しておくと、移行時のトラブルシューティングがスムーズになります。
セットアップと基本的な書き方
導入は非常にシンプルです。Viteプロジェクトであれば、パッケージを追加して設定ファイルに数行書くだけで始められます。
npm install -D vitestvite.config.tsにテスト設定を追加します。
import { defineConfig } from 'vitest/config'
export default defineConfig({
test: {
globals: true,
environment: 'jsdom',
},
})globals: trueを設定すると、describeやitをインポートなしで使用できます。最初はこの設定を見落としていて、毎回import { describe, it, expect } from 'vitest'と書いていたのですが、グローバル設定を有効にするだけで記述量がかなり減りました。
なお、globals: trueを使う場合はTypeScriptの型定義も合わせて設定しておくと安心です。tsconfig.jsonに以下を追加することで、エディタ上での型補完が効くようになります。
{
"compilerOptions": {
"types": ["vitest/globals"]
}
}この設定を忘れると、エディタ上でdescribeやexpectに赤線が表示されてしまいます。テスト自体は実行できるのですが、開発体験としてはやはりストレスになるので、最初に済ませておくことをおすすめします。
テストファイルの基本的な構造は以下のとおりです。
describe('formatPrice', () => {
it('数値を3桁カンマ区切りで返す', () => {
expect(formatPrice(1000)).toBe('1,000')
})
it('0の場合は"0"を返す', () => {
expect(formatPrice(0)).toBe('0')
})
it('負の値にもカンマ区切りを適用する', () => {
expect(formatPrice(-1500)).toBe('-1,500')
})
})ファイル名は*.test.tsまたは*.spec.tsがデフォルトで認識されます。テストの配置戦略としては、対象ファイルと同階層に置くコロケーションパターンと、__tests__ディレクトリにまとめるパターンのどちらも選べます。プロジェクトの規模や好みに応じて使い分ければよいかもしれません。
テスト環境の選択──jsdom、happy-dom、node
Vitestでは、テストの実行環境をenvironmentオプションで指定します。主な選択肢は3つです。
node:デフォルト。DOMを使わないユーティリティ関数やAPIロジックのテストに適していますjsdom:ブラウザのDOMをNode.js上でエミュレートする最も広く使われている環境です。Reactコンポーネントのレンダリングテストなどで定番ですhappy-dom:jsdomの軽量代替として注目を集めている環境です。jsdomよりも高速に動作する一方、一部のWeb APIの実装が省略されている場合があります
export default defineConfig({
test: {
environment: 'happy-dom',
},
})筆者の経験では、DOMを扱うコンポーネントテストが多いプロジェクトでhappy-domに切り替えたところ、テスト実行速度が約20%向上しました。ただし、window.getComputedStyleなど一部のAPIが期待通りに動作しないケースに遭遇したこともあるため、テストの内容に応じた選択が必要です。
また、ファイル単位で環境を切り替えられる点もVitestの柔軟な特徴です。テストファイルの先頭にマジックコメントを記述するだけで、そのファイルだけ異なる環境で実行できます。
// @vitest-environment jsdom
import { render } from '@testing-library/react'
describe('Button', () => {
it('クリックイベントを発火する', () => {
// jsdom環境で実行される
})
})ユーティリティ関数のテストはnode環境、コンポーネントテストはjsdom環境というように、ファイルごとに最適な環境を選べるのは便利な仕組みです。
モックとスパイ──外部依存を制御する
テストで避けて通れないのがモックの扱いです。Vitestはviオブジェクトを通じて、Jestのjestオブジェクトに相当する機能を提供しています。
import { vi } from 'vitest'
// 関数のモック
const mockFn = vi.fn()
mockFn.mockReturnValue(42)
// モジュール全体のモック
vi.mock('./api', () => ({
fetchUser: vi.fn().mockResolvedValue({ name: 'Taro' }),
}))
// タイマーのモック
vi.useFakeTimers()
vi.advanceTimersByTime(1000)vi.spyOnを使えば、既存オブジェクトのメソッドを監視しつつ元の実装を保持することもできます。APIフェッチのテストではvi.fn()でモック関数を差し込むのか、MSW(Mock Service Worker)でネットワーク層をインターセプトするのか、プロジェクトによって判断が分かれるところです。私自身、AとBで悩んだ末に、ユニットテストではvi.mock、統合テストではMSWという使い分けに落ち着きました。一概にどちらが正解とは言えない部分もありますが、テストの粒度に応じた選択が重要ではないでしょうか。
注意点として、vi.mockはファイルのトップレベルに巻き上げ(hoist)されます。動的にモックの振る舞いを変えたい場合は、vi.hoistedを活用するか、各テスト内でmockReturnValueを上書きする方法を検討してください。
vi.hoistedの実践的な使い方
vi.hoistedはVitest 1.0で追加された機能で、vi.mockのコールバック内から外部の変数を安全に参照するための仕組みです。vi.mockはファイルの先頭に巻き上げられるため、通常のスコープにある変数にはアクセスできません。vi.hoistedを使うと、モックと同じタイミングで変数を初期化できます。
const { mockFetchUser } = vi.hoisted(() => ({
mockFetchUser: vi.fn(),
}))
vi.mock('./api', () => ({
fetchUser: mockFetchUser,
}))
describe('UserProfile', () => {
it('ユーザー名を表示する', async () => {
mockFetchUser.mockResolvedValue({ name: 'Taro' })
// テスト本体
})
it('エラー時にフォールバック表示する', async () => {
mockFetchUser.mockRejectedValue(new Error('Network Error'))
// テスト本体
})
})この書き方であれば、テストケースごとにモックの戻り値を変えられるため、正常系と異常系を一つのファイルで網羅しやすくなります。以前はvi.mockのコールバック内にすべてのロジックを詰め込みがちでしたが、vi.hoistedを知ってからはモックの見通しがかなり良くなりました。
日時のモックとタイムゾーンの扱い
日時に依存するロジックのテストは、思わぬところで躓きやすいポイントです。vi.useFakeTimersを使えばシステム時刻を固定できますが、setSystemTimeと組み合わせることで、特定の日時を再現したテストが書けます。
describe('isBusinessHour', () => {
beforeEach(() => {
vi.useFakeTimers()
})
afterEach(() => {
vi.useRealTimers()
})
it('平日9時〜17時はtrueを返す', () => {
// 2025年4月7日(月曜)10:00 JST
vi.setSystemTime(new Date('2025-04-07T01:00:00Z'))
expect(isBusinessHour()).toBe(true)
})
it('土日はfalseを返す', () => {
// 2025年4月5日(土曜)10:00 JST
vi.setSystemTime(new Date('2025-04-05T01:00:00Z'))
expect(isBusinessHour()).toBe(false)
})
})afterEachでvi.useRealTimers()を呼んでタイマーを復元することを忘れると、後続のテストに影響が波及してしまいます。テストが個別に通るのにスイート全体で実行すると落ちる、という原因の多くはこうしたクリーンアップ漏れにあります。
Reactコンポーネントのテスト
VitestはTesting Libraryとの組み合わせで、Reactコンポーネントのテストにも活用できます。@testing-library/reactとjsdom環境を使った基本的な流れを見てみましょう。
npm install -D @testing-library/react @testing-library/jest-dom @testing-library/user-eventセットアップファイルを作成して、カスタムマッチャーを登録しておくと便利です。
// vitest.setup.ts
import '@testing-library/jest-dom/vitest'// vite.config.ts
export default defineConfig({
test: {
globals: true,
environment: 'jsdom',
setupFiles: ['./vitest.setup.ts'],
},
})コンポーネントテストの例を示します。
import { render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { Counter } from './Counter'
describe('Counter', () => {
it('初期値が0で表示される', () => {
render(<Counter />)
expect(screen.getByText('カウント: 0')).toBeInTheDocument()
})
it('ボタンクリックでカウントが増加する', async () => {
const user = userEvent.setup()
render(<Counter />)
await user.click(screen.getByRole('button', { name: '増やす' }))
expect(screen.getByText('カウント: 1')).toBeInTheDocument()
})
it('上限に達するとボタンが無効化される', async () => {
const user = userEvent.setup()
render(<Counter max={2} />)
await user.click(screen.getByRole('button', { name: '増やす' }))
await user.click(screen.getByRole('button', { name: '増やす' }))
expect(screen.getByRole('button', { name: '増やす' })).toBeDisabled()
})
})@testing-library/jest-dom/vitestをインポートすることで、toBeInTheDocumentやtoBeDisabledといったDOM専用のマッチャーが使えるようになります。以前は@testing-library/jest-domだけをインポートしていたのですが、Vitest用のエントリポイントを使うことで型定義がより正確になります。
ユーザー操作のシミュレーションにはfireEventよりもuserEventを使うことをおすすめします。userEventは実際のユーザー操作により近い挙動(フォーカス移動やキーボードイベントの発火など)を再現するため、実際のブラウザ動作との乖離が少ないテストが書けます。
カバレッジ計測とCIへの組み込み
テストを書くだけでなく、カバレッジを可視化することでテストの網羅性を客観的に把握できます。Vitestは@vitest/coverage-v8または@vitest/coverage-istanbulをプロバイダとして選択可能です。
npm install -D @vitest/coverage-v8// vite.config.ts
export default defineConfig({
test: {
coverage: {
provider: 'v8',
reporter: ['text', 'html', 'json-summary'],
include: ['src/**/*.ts'],
exclude: ['src/**/*.test.ts', 'src/**/*.d.ts'],
},
},
})npx vitest run --coverageで実行すると、ターミナルにサマリが表示され、coverageディレクトリにHTMLレポートが生成されます。GitHub ActionsなどのCI環境では、json-summary形式で出力してカバレッジバッジに反映させるワークフローも構築しやすいです。
CI環境での実行時はvitest run(ウォッチモードなし)を使う点と、--reporter=junitでJUnit形式のレポートを出力するとCI側での解析がスムーズになる点も覚えておくと便利です。振り返ると、最初にカバレッジのexclude設定を忘れてテストファイル自体のカバレッジが計上されてしまい、数値が不正確になったことがありました。こうした細かな設定の抜け漏れは、早めに気づいて対処しておきたいところです。
GitHub Actionsでの実践的なワークフロー例
CI環境での構成例を具体的に示します。以下はGitHub Actionsでテスト実行とカバレッジレポートを自動化するワークフローです。
name: Test
on:
pull_request:
branches: [main]
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 20
cache: 'npm'
- run: npm ci
- run: npx vitest run --coverage --reporter=junit --outputFile=test-results.xml
- uses: actions/upload-artifact@v4
if: always()
with:
name: coverage-report
path: coverage/if: always()を付けることで、テストが失敗した場合でもカバレッジレポートがアーティファクトとして保存されます。テストが落ちたときこそカバレッジの詳細を確認したいケースは多いので、この設定は地味ながら役に立ちます。
カバレッジしきい値の設定
チームで開発している場合、カバレッジが一定水準を下回ったらCIを失敗させる設定も検討に値します。
export default defineConfig({
test: {
coverage: {
provider: 'v8',
thresholds: {
statements: 80,
branches: 75,
functions: 80,
lines: 80,
},
},
},
})ただし、カバレッジの数値目標は手段であって目的ではありません。「80%を達成するためにアサーションのない空テストを書く」といった本末転倒な状況は避けたいところです。カバレッジはあくまで「テストされていない箇所を発見するための指標」として活用するのが健全な付き合い方ではないでしょうか。
ウォッチモードとDX──開発体験を高める機能
Vitestが開発者体験の面で優れていると感じるのは、デフォルトで有効になるウォッチモードの速さです。Viteのモジュールグラフを活用し、変更されたファイルに関連するテストだけを再実行するため、大規模プロジェクトでもフィードバックループが非常に短く保たれます。
vitest --uiで起動するブラウザベースのUIダッシュボードも便利な機能です。テストの一覧、実行状態、カバレッジをブラウザ上で確認でき、特定のテストだけをフィルタして実行することもできます。ターミナル出力だけでは見づらいと感じていた方には、一度試してみる価値があるのではないでしょうか。
インラインスナップショット(toMatchInlineSnapshot)もJestから引き継がれた便利な機能です。通常のスナップショットとは異なり、期待値がテストコード内に直接書き込まれるため、別ファイルを開かなくても期待値が一目で分かります。
it('ユーザー情報を整形する', () => {
expect(formatUser({ name: 'Taro', age: 30 })).toMatchInlineSnapshot(`
"Taro (30歳)"
`)
})テストのフィルタリングと実行制御
開発中に特定のテストだけを実行したい場面は頻繁にあります。Vitestにはいくつかのフィルタリング手段が用意されています。
コマンドラインからファイル名パターンで絞り込む方法が最もシンプルです。
# 特定のファイルだけ実行
npx vitest src/utils/format.test.ts
# パターンでフィルタ
npx vitest --testNamePattern="カンマ区切り"テストコード内で一時的に特定のテストだけを実行したい場合は、it.onlyやdescribe.onlyが使えます。
describe('formatPrice', () => {
it.only('このテストだけ実行される', () => {
expect(formatPrice(1000)).toBe('1,000')
})
it('このテストはスキップされる', () => {
expect(formatPrice(0)).toBe('0')
})
})逆に、まだ実装が追いついていないテストを一時的にスキップするにはit.skipやit.todoを使います。it.todoはテスト本体を書かずにテスト名だけを宣言でき、「これから書くべきテスト」を可視化するのに便利です。
it.todo('小数点を含む値の処理')
it.todo('通貨記号の付与')こうしたTODOテストは、実装の合間に思いついたテスト観点をメモ代わりに残しておく使い方ができます。筆者もコードレビュー中に「このケースのテストがあるとよさそうだな」と気づいたとき、it.todoで残しておくようにしています。
VSCode拡張との連携
VitestにはVSCode向けの公式拡張機能が提供されています。テストエクスプローラーとの統合により、エディタのサイドバーからテストの実行・デバッグが可能です。
特に便利なのが、テストファイルを開いた状態で個別のテストケースの横に表示される実行ボタンです。ターミナルに切り替えることなく、エディタ上でテストの実行と結果の確認が完結します。ブレークポイントを設定してのステップ実行デバッグにも対応しているため、テストが意図通りに動かないときの原因調査が格段に効率化されます。
並列実行とパフォーマンスチューニング
Vitestはデフォルトでテストファイルを並列に実行します。これにより、テストスイート全体の実行時間が短縮されます。ただし、並列実行が原因でテストが不安定になるケースもあるため、その仕組みを理解しておくことは重要です。
テストファイル間は並列に実行されますが、同一ファイル内のテストケースはデフォルトで直列に実行されます。ファイル内のテストも並列化したい場合は、concurrentを指定します。
describe('非同期処理のテスト', () => {
it.concurrent('APIからユーザー一覧を取得する', async () => {
const users = await fetchUsers()
expect(users).toHaveLength(3)
})
it.concurrent('APIから商品一覧を取得する', async () => {
const products = await fetchProducts()
expect(products).toHaveLength(5)
})
})一方で、グローバルな状態を共有するテスト(データベース操作やファイルシステムの読み書きなど)は、並列実行すると競合が発生する可能性があります。そのような場合は--sequenceオプションやdescribe.sequentialで直列実行を強制できます。
describe.sequential('データベースのCRUD操作', () => {
it('レコードを作成する', async () => { /* ... */ })
it('作成したレコードを読み取る', async () => { /* ... */ })
it('レコードを更新する', async () => { /* ... */ })
it('レコードを削除する', async () => { /* ... */ })
})テストの実行順序に依存したテストは設計として望ましくありませんが、統合テストなどではやむを得ないケースもあります。そうした場合にVitestが柔軟に対応できるのはありがたい点です。
まとめ:Vitestを選ぶ判断基準
Vitestは、Viteベースのプロジェクトにおいて最も自然な選択肢であり、ESMネイティブ対応・高速なウォッチモード・Jest互換APIという三つの強みが際立っています。既存のJestプロジェクトからの移行も、多くの場合はAPIの差分が小さいため段階的に進められます。
一方で、Viteを使っていないプロジェクトや、Jest固有のエコシステム(特定のカスタムマッチャーやトランスフォーマー)に強く依存している場合は、移行コストと得られるメリットを天秤にかける必要があります。
判断の目安として、以下のような整理が参考になるかもしれません。
- Vitestを積極的に選びたいケース:Viteベースのプロジェクト、新規プロジェクトの立ち上げ、ESMへの移行を進めている段階
- 移行を慎重に検討すべきケース:webpack中心の大規模プロジェクト、Jestのカスタムトランスフォーマーに強く依存している場合、テストコードが数千ファイル規模で一括移行のリスクが高い場合
テスト環境の選定や構築でお困りのことがあれば、aduceのお問い合わせはこちらからお気軽にご相談ください。プロジェクトの状況に合わせた最適なテスト戦略をご提案いたします。
