VitestとReactTestingLibraryによるモダンなReactテスト

Table of Contents
Reactアプリケーションのテストは、長年にわたり大きく進化してきました。長い間、JestはJavaScriptテストエコシステムにおいて議論の余地のない王者でした。しかし、業界がESモジュール(ESM)やViteのような最新のビルドツールに移行するにつれて、Jestは特に実行速度とESM互換性に関して、その古さが目立つようになりました。そこに登場したのがVitestです。これは、Viteネイティブの超高速テストフレームワークであり、Reactプロジェクトの新しい標準となりつつあります。
この包括的なガイドでは、JestからVitestへの移行、React Testing Library(RTL)との統合、そして従来のチュートリアルを超える最新のテストパターンを活用する方法について深く掘り下げていきます。
JestからVitestへ移行する理由
コードに飛び込む前に、なぜこの変化が起きているのかを理解することが重要です。Robin Wieruchのような包括的なチュートリアルが歴史的にJestを支持してきた一方で、現代のフロントエンドの状況は、より速いイテレーションサイクルを求めています。
- ネイティブESMサポート: Jestは歴史的にESMとの連携に苦労しており、複雑なBabel設定や実験的なフラグが必要となることがよくありました。VitestはESMをすぐに利用できます。
- Vite統合: ReactアプリがすでにViteを使用している場合、Vitestは同じ設定(
vite.config.ts)、プラグイン、変換パイプラインを共有します。これにより、「開発環境では動作するが、テストでは失敗する」という恐ろしい不一致が解消されます。 - 速度: ESBuildを変換に利用することで、VitestはJestよりもはるかに高速にテストを実行します。特に大規模なコードベースでは顕著です。
- ウォッチモード: Vitestのインテリジェントなウォッチモード(ViteのHMRメカニズムによって強化されています)は、ファイル変更の影響を受けるテストのみを即座に再実行します。
- API互換性: VitestはJest互換のAPIを提供しており、移行は驚くほど簡単です。
describe、it、expect、およびvi(jestに相当するVitest)は、ほとんどの場合、そのまま置き換え可能です。
ステップ1:インストールと設定
まず、Jestを削除し、VitestとReact Testing Libraryをインストールすることから始めましょう。
npm uninstall jest @types/jest ts-jest @testing-library/jest-dom
npm install -D vitest @vitest/ui @testing-library/react @testing-library/jest-dom jsdom @testing-library/user-event
次に、Vitestを設定します。Viteを使用している場合は、vite.config.tsにtestプロパティを追加するだけです。
// vite.config.ts
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
import path from 'path';
export default defineConfig({
plugins: [react()],
test: {
globals: true, // Enables 'describe', 'it', 'expect' globally like Jest
environment: 'jsdom', // Simulates a browser environment for React components
setupFiles: './src/setupTests.ts', // Path to your setup file
css: true, // Process CSS imports
alias: {
'@': path.resolve(__dirname, './src'),
},
},
});
環境のセットアップ
src/setupTests.tsを作成して、Vitestのexpectを@testing-library/jest-domからのDOMマッチャーで拡張します。
// src/setupTests.ts
import '@testing-library/jest-dom/vitest';
import { cleanup } from '@testing-library/react';
import { afterEach, vi } from 'vitest';
// Automatically unmount and cleanup DOM after the test is finished.
afterEach(() => {
cleanup();
});
// Mocking window.matchMedia if needed for UI libraries
Object.defineProperty(window, 'matchMedia', {
writable: true,
value: vi.fn().mockImplementation(query => ({
matches: false,
media: query,
onchange: null,
addListener: vi.fn(),
removeListener: vi.fn(),
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
dispatchEvent: vi.fn(),
})),
});
@testing-library/jest-dom/vitestをインポートしていることに注目してください。これは、ネイティブのVitest型を提供し、Jestとの名前空間の衝突を避けるための新しい追加機能です。
ステップ2:移行プロセス(JestからVitestへ)
テストファイルの移行は、通常、検索と置換の操作です。
- グローバル: 設定で
globals: trueを設定している場合、vitestからdescribe、it、またはexpectをインポートする必要はありません。 - モック:
jest.fn()をvi.fn()に、jest.spyOn()をvi.spyOn()に、jest.mock()をvi.mock()に置き換えます。 - タイマー:
jest.useFakeTimers()をvi.useFakeTimers()に置き換えます。
高度なモックの例
モジュールのモックがどのように変わるかを見てみましょう。
Jest:
jest.mock('axios', () => ({
default: { get: jest.fn().mockResolvedValue({ data: {} }) }
}));
Vitest:
import { vi } from 'vitest';
import axios from 'axios';
vi.mock('axios', () => ({
default: { get: vi.fn().mockResolvedValue({ data: {} }) }
}));
構造は同じですが、Vitestの巻き上げ(hoisting)メカニズムはわずかに異なります。VitestはJestと同様にvi.mockをファイルの先頭に巻き上げますが、ESMとの統合がよりシームレスであるため、モックが適用される前にモックされたモジュールが評価されるというエッジケースが減少します。
ステップ3:React Testing Libraryによる最新のコンポーネントテスト
React Testing Library(RTL)は、実装の詳細をテストするのではなく、ユーザーの視点からコンポーネントをテストすることを推奨しています。複雑な非同期コンポーネントを構築し、適切にテストしてみましょう。
// src/components/UserProfile.tsx
import React, { useState, useEffect } from 'react';
interface User {
id: string;
name: string;
email: string;
}
export const UserProfile = ({ userId }: { userId: string }) => {
const [user, setUser] = useState<User | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
const fetchUser = async () => {
try {
setLoading(true);
const response = await fetch(`/api/users/${userId}`);
if (!response.ok) throw new Error('Failed to fetch user');
const data = await response.json();
setUser(data);
} catch (err) {
setError(err instanceof Error ? err.message : 'Unknown error');
} finally {
setLoading(false);
}
};
fetchUser();
}, [userId]);
if (loading) return <div role="status">Loading...</div>;
if (error) return <div role="alert">{error}</div>;
if (!user) return <div>No user found</div>;
return (
<article>
<h2>{user.name}</h2>
<p>{user.email}</p>
<button onClick={() => console.log('Edit clicked')}>Edit Profile</button>
</article>
);
};
テストの記述
以下は、VitestとRTLを使用して、このコンポーネントの最新で堅牢なテストスイートを作成する方法です。waitForを不必要に使用するなどの一般的なアンチパターンを避けています。
// src/components/UserProfile.test.tsx
import { render, screen, waitForElementToBeRemoved } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { UserProfile } from './UserProfile';
import { vi, describe, it, expect, beforeEach } from 'vitest';
// Mock the global fetch
global.fetch = vi.fn();
describe('UserProfile Component', () => {
const mockUser = { id: '1', name: 'Jane Doe', email: 'jane@example.com' };
beforeEach(() => {
vi.clearAllMocks();
});
it('renders loading state initially, then displays user data', async () => {
(global.fetch as any).mockResolvedValueOnce({
ok: true,
json: async () => mockUser,
});
render(<UserProfile userId="1" />);
// 1. Assert loading state
expect(screen.getByRole('status')).toHaveTextContent('Loading...');
// 2. Wait for loading to finish (Modern pattern instead of waitFor)
await waitForElementToBeRemoved(() => screen.queryByRole('status'));
// 3. Assert user data
expect(screen.getByRole('heading', { name: 'Jane Doe' })).toBeInTheDocument();
expect(screen.getByText('jane@example.com')).toBeInTheDocument();
});
it('renders error state on API failure', async () => {
(global.fetch as any).mockResolvedValueOnce({
ok: false,
});
render(<UserProfile userId="2" />);
// Wait for the alert role to appear
const alertElement = await screen.findByRole('alert');
expect(alertElement).toHaveTextContent('Failed to fetch user');
});
it('simulates user interaction correctly', async () => {
(global.fetch as any).mockResolvedValueOnce({
ok: true,
json: async () => mockUser,
});
const consoleSpy = vi.spyOn(console, 'log');
render(<UserProfile userId="1" />);
// Wait for data to load using findByRole
const editButton = await screen.findByRole('button', { name: /edit profile/i });
// Use userEvent instead of fireEvent for more realistic interactions
const user = userEvent.setup();
await user.click(editButton);
expect(consoleSpy).toHaveBeenCalledWith('Edit clicked');
});
});
テストスイートからの重要なポイント:
userEventよりもfireEvent:@testing-library/user-eventを利用しています。これは、完全なDOMイベント(キーボードフォーカス、バブリングなど)をシミュレートし、テストを実際のユーザーがブラウザとどのようにやり取りするかに大幅に近づけます。findBy*の代わりにwaitFor:getBy*をwaitForブロックで囲む代わりに、最新のRTLはawait screen.findBy*()を使用することを推奨しています。これはよりクリーンで、ポーリングを内部で処理します。waitForElementToBeRemoved: これは、ローディングスケルトンやスピナーが消えたことをアサートし、その後レンダリングされたコンテンツをアサートする最もクリーンな方法です。
継続的インテグレーションとカバレッジ
Vitestは、コードカバレッジのためにc8(v8経由)またはistanbulを使用します。カバレッジを有効にするには、以下を実行します。
npx vitest run --coverage
プロバイダーをインストールする必要があるかもしれません:npm i -D @vitest/coverage-v8。出力は即座に生成され、ESBuild上で実行されるため、CIパイプラインで驚くほど高速です。
結論
JestからVitestへの移行は、単にトレンドに追いつくことだけではありません。それは開発者体験のための実用的なアップグレードです。ネイティブESMサポート、Viteとの共有設定、そしてCI時間を半分に短縮できる実行速度が得られます。
React Testing Libraryの哲学的アプローチ(実装の詳細ではなく振る舞いをテストする)と組み合わせることで、テストスイートは壊れやすいメンテナンスの負担ではなく、堅牢なセーフティネットになります。Vitestを採用し、userEventを活用し、コンポーネントの内部状態をテストするのをやめましょう。ハッピーテスティング!
こちらもおすすめ
Free In-Browser Developer Tools
Clean AI CLI logs, build cron expressions, decode JWTs, and calculate chmod permissions offline.
Related Articles

React Server Componentsがもたらすパラダイムシフト
React Server Components (RSC)がバンドルサイズの縮小、データ取得の簡素化、パフォーマンス向上を実現し、Reactアプリケーション構築のあり方を根本的に変える方法を探ります。
Read more
TurborepoモノレポアーキテクチャにおけるJestからVitestへの移行
TypeScript TurborepoモノレポでJestテストスイートをVitestへ移行し、実行時間を10倍高速化するための完全なステップバイステップガイド。
Read more
fireEventを使うのをやめよう: React Testing user-event v14ガイド
React Testing LibraryでfireEventを使うのをやめるべき理由を、@testing-library/user-event v14、userEvent.setup()、非同期タイピング、アクセシブルなクエリ、MSWモックの完全ガイドで解説します。
Read more