Vitestテスト戦略実践ガイド - 高速で効率的なテスト駆動開発

中級 | 40分 で読める | 2025.12.02

公式ドキュメント

今回やること

Vitestは、Viteベースの超高速なテストフレームワークです。Jest互換のAPIを持ちながら、ESMネイティブサポートとHMR(Hot Module Replacement)による高速な開発体験を提供します。

この記事では、まず小さな関数をテストし、その後にモック、カバレッジ、CIでの実行へ広げます。

ポイント: VitestはViteの設定をそのまま再利用できるため、追加の設定がほぼ不要です。エイリアス解決やTypeScriptのトランスパイルがゼロコンフィグで動作します。

Vitestの基本設定

インストールとセットアップ

# インストール
npm install -D vitest @vitest/coverage-v8 @vitest/ui

# TypeScript対応
npm install -D @types/node

設定ファイル

// vitest.config.ts
import { defineConfig } from 'vitest/config';
import react from '@vitejs/plugin-react';
import path from 'path';

export default defineConfig({
 plugins: [react()],
 test: {
 // 環境設定
 environment: 'jsdom', // 'node', 'jsdom', 'happy-dom'

 // グローバルAPI
 globals: true,

 // セットアップファイル
 setupFiles: ['./src/test/setup.ts'],

 // インクルード/除外パターン
 include: ['src/**/*.{test,spec}.{ts,tsx}'],
 exclude: ['node_modules', 'dist', 'e2e'],

 // カバレッジ設定
 coverage: {
 provider: 'v8',
 reporter: ['text', 'json', 'html'],
 exclude: [
 'node_modules/',
 'src/test/',
 '**/*.d.ts',
 '**/*.config.*',
 '**/types/*',
 ],
 thresholds: {
 lines: 80,
 functions: 80,
 branches: 80,
 statements: 80,
 },
 },

 // エイリアス
 alias: {
 '@': path.resolve(__dirname, './src'),
 '@test': path.resolve(__dirname, './src/test'),
 },

 // 並列実行設定
 pool: 'threads',
 poolOptions: {
 threads: {
 singleThread: false,
 maxThreads: 4,
 minThreads: 1,
 },
 },

 // タイムアウト
 testTimeout: 10000,
 hookTimeout: 10000,

 // レポーター
 reporters: ['verbose'],

 // ウォッチモード除外
 watchExclude: ['**/node_modules/**', '**/dist/**'],
 },
 resolve: {
 alias: {
 '@': path.resolve(__dirname, './src'),
 },
 },
});

セットアップファイル

// src/test/setup.ts
import '@testing-library/jest-dom/vitest';
import { cleanup } from '@testing-library/react';
import { afterEach, beforeAll, vi } from 'vitest';

// 各テスト後にクリーンアップ
afterEach(() => {
 cleanup();
});

// グローバルモック
beforeAll(() => {
 // ResizeObserverのモック
 global.ResizeObserver = vi.fn().mockImplementation(() => ({
 observe: vi.fn(),
 unobserve: vi.fn(),
 disconnect: vi.fn(),
 }));

 // IntersectionObserverのモック
 global.IntersectionObserver = vi.fn().mockImplementation(() => ({
 observe: vi.fn(),
 unobserve: vi.fn(),
 disconnect: vi.fn(),
 }));

 // matchMediaのモック
 Object.defineProperty(window, 'matchMedia', {
 writable: true,
 value: vi.fn().mockImplementation((query: string) => ({
 matches: false,
 media: query,
 onchange: null,
 addListener: vi.fn(),
 removeListener: vi.fn(),
 addEventListener: vi.fn(),
 removeEventListener: vi.fn(),
 dispatchEvent: vi.fn(),
 })),
 });
});

// 環境変数のモック
vi.stubEnv('NODE_ENV', 'test');
vi.stubEnv('API_URL', 'http://localhost:3000');

ユニットテスト

基本的なテスト

// utils/format.ts
export function formatCurrency(amount: number, currency: string = 'JPY'): string {
 return new Intl.NumberFormat('ja-JP', {
 style: 'currency',
 currency,
 }).format(amount);
}

export function formatDate(date: Date | string, format: string = 'short'): string {
 const d = typeof date === 'string' ? new Date(date) : date;

 const options: Intl.DateTimeFormatOptions = format === 'short'
 ? { year: 'numeric', month: '2-digit', day: '2-digit' }
 : { year: 'numeric', month: 'long', day: 'numeric', weekday: 'long' };

 return new Intl.DateTimeFormat('ja-JP', options).format(d);
}

export function slugify(text: string): string {
 return text
 .toLowerCase()
 .trim()
 .replace(/[^\w\s-]/g, '')
 .replace(/[\s_-]+/g, '-')
 .replace(/^-+|-+$/g, '');
}
// utils/format.test.ts
import { describe, it, expect } from 'vitest';
import { formatCurrency, formatDate, slugify } from './format';

describe('formatCurrency', () => {
 it('should format JPY correctly', () => {
 expect(formatCurrency(1000)).toBe('¥1,000');
 expect(formatCurrency(1234567)).toBe('¥1,234,567');
 });

 it('should handle zero and negative numbers', () => {
 expect(formatCurrency(0)).toBe('¥0');
 expect(formatCurrency(-1000)).toBe('-¥1,000');
 });

 it('should format USD correctly', () => {
 expect(formatCurrency(1000, 'USD')).toBe('$1,000.00');
 });
});

describe('formatDate', () => {
 it('should format date in short format', () => {
 const date = new Date('2025-12-02');
 expect(formatDate(date)).toBe('2025/01/02');
 });

 it('should format date string', () => {
 expect(formatDate('2025-12-02')).toBe('2025/01/02');
 });

 it('should format date in long format', () => {
 const date = new Date('2025-12-02');
 const result = formatDate(date, 'long');
 expect(result).toContain('2025年');
 expect(result).toContain('1月');
 expect(result).toContain('2日');
 });
});

describe('slugify', () => {
 it('should convert text to slug', () => {
 expect(slugify('Hello World')).toBe('hello-world');
 expect(slugify('TypeScript 入門')).toBe('typescript');
 });

 it('should handle special characters', () => {
 expect(slugify('Hello! World?')).toBe('hello-world');
 expect(slugify('a & b')).toBe('a-b');
 });

 it('should trim and handle multiple spaces', () => {
 expect(slugify(' Hello World ')).toBe('hello-world');
 expect(slugify('hello---world')).toBe('hello-world');
 });
});

非同期テスト

// services/api.ts
export interface User {
 id: string;
 name: string;
 email: string;
}

export async function fetchUser(id: string): Promise<User> {
 const response = await fetch(`/api/users/${id}`);

 if (!response.ok) {
 throw new Error(`User not found: ${id}`);
 }

 return response.json();
}

export async function createUser(data: Omit<User, 'id'>): Promise<User> {
 const response = await fetch('/api/users', {
 method: 'POST',
 headers: { 'Content-Type': 'application/json' },
 body: JSON.stringify(data),
 });

 if (!response.ok) {
 throw new Error('Failed to create user');
 }

 return response.json();
}
// services/api.test.ts
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { fetchUser, createUser } from './api';

// fetchのモック
const mockFetch = vi.fn();
global.fetch = mockFetch;

describe('API Service', () => {
 beforeEach(() => {
 mockFetch.mockReset();
 });

 afterEach(() => {
 vi.restoreAllMocks();
 });

 describe('fetchUser', () => {
 it('should fetch user successfully', async () => {
 const mockUser = { id: '1', name: 'John', email: 'john@example.com' };

 mockFetch.mockResolvedValueOnce({
 ok: true,
 json: () => Promise.resolve(mockUser),
 });

 const user = await fetchUser('1');

 expect(user).toEqual(mockUser);
 expect(mockFetch).toHaveBeenCalledWith('/api/users/1');
 });

 it('should throw error when user not found', async () => {
 mockFetch.mockResolvedValueOnce({
 ok: false,
 status: 404,
 });

 await expect(fetchUser('999')).rejects.toThrow('User not found: 999');
 });

 it('should handle network error', async () => {
 mockFetch.mockRejectedValueOnce(new Error('Network error'));

 await expect(fetchUser('1')).rejects.toThrow('Network error');
 });
 });

 describe('createUser', () => {
 it('should create user successfully', async () => {
 const newUser = { name: 'Jane', email: 'jane@example.com' };
 const createdUser = { id: '2', ...newUser };

 mockFetch.mockResolvedValueOnce({
 ok: true,
 json: () => Promise.resolve(createdUser),
 });

 const user = await createUser(newUser);

 expect(user).toEqual(createdUser);
 expect(mockFetch).toHaveBeenCalledWith('/api/users', {
 method: 'POST',
 headers: { 'Content-Type': 'application/json' },
 body: JSON.stringify(newUser),
 });
 });
 });
});

モック戦略

モジュールモック

// services/userService.ts
import { prisma } from '@/lib/prisma';
import { sendEmail } from '@/lib/email';

export async function registerUser(email: string, password: string) {
 const user = await prisma.user.create({
 data: { email, password },
 });

 await sendEmail({
 to: email,
 subject: 'Welcome!',
 body: 'Thank you for registering.',
 });

 return user;
}
// services/userService.test.ts
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { registerUser } from './userService';

// モジュールモック
vi.mock('@/lib/prisma', () => ({
 prisma: {
 user: {
 create: vi.fn(),
 findUnique: vi.fn(),
 findMany: vi.fn(),
 },
 },
}));

vi.mock('@/lib/email', () => ({
 sendEmail: vi.fn(),
}));

// モックのインポート
import { prisma } from '@/lib/prisma';
import { sendEmail } from '@/lib/email';

describe('registerUser', () => {
 beforeEach(() => {
 vi.clearAllMocks();
 });

 it('should create user and send welcome email', async () => {
 const mockUser = { id: '1', email: 'test@example.com', password: 'hashed' };

 vi.mocked(prisma.user.create).mockResolvedValue(mockUser);
 vi.mocked(sendEmail).mockResolvedValue(undefined);

 const result = await registerUser('test@example.com', 'password123');

 expect(result).toEqual(mockUser);
 expect(prisma.user.create).toHaveBeenCalledWith({
 data: { email: 'test@example.com', password: 'password123' },
 });
 expect(sendEmail).toHaveBeenCalledWith({
 to: 'test@example.com',
 subject: 'Welcome!',
 body: 'Thank you for registering.',
 });
 });

 it('should not send email if user creation fails', async () => {
 vi.mocked(prisma.user.create).mockRejectedValue(new Error('DB Error'));

 await expect(registerUser('test@example.com', 'password'))
 .rejects.toThrow('DB Error');

 expect(sendEmail).not.toHaveBeenCalled();
 });
});

スパイとスタブ

// utils/logger.test.ts
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';

class Logger {
 log(message: string) {
 console.log(`[LOG] ${message}`);
 }

 error(message: string) {
 console.error(`[ERROR] ${message}`);
 }

 async logAsync(message: string) {
 await new Promise(resolve => setTimeout(resolve, 100));
 console.log(`[ASYNC] ${message}`);
 }
}

describe('Logger', () => {
 let logger: Logger;
 let consoleSpy: ReturnType<typeof vi.spyOn>;

 beforeEach(() => {
 logger = new Logger();
 consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
 });

 afterEach(() => {
 consoleSpy.mockRestore();
 });

 it('should log message with prefix', () => {
 logger.log('test message');

 expect(consoleSpy).toHaveBeenCalledWith('[LOG] test message');
 expect(consoleSpy).toHaveBeenCalledTimes(1);
 });

 it('should spy on method calls', () => {
 const logSpy = vi.spyOn(logger, 'log');

 logger.log('first');
 logger.log('second');

 expect(logSpy).toHaveBeenCalledTimes(2);
 expect(logSpy.mock.calls).toEqual([['first'], ['second']]);
 });

 it('should mock method implementation', () => {
 const mockLog = vi.spyOn(logger, 'log').mockImplementation((msg) => {
 console.log(`[MOCKED] ${msg}`);
 });

 logger.log('test');

 expect(consoleSpy).toHaveBeenCalledWith('[MOCKED] test');
 });
});

タイマーモック

// utils/debounce.ts
export function debounce<T extends (...args: any[]) => any>(
 fn: T,
 delay: number
): (...args: Parameters<T>) => void {
 let timeoutId: NodeJS.Timeout | null = null;

 return (...args: Parameters<T>) => {
 if (timeoutId) {
 clearTimeout(timeoutId);
 }
 timeoutId = setTimeout(() => fn(...args), delay);
 };
}
// utils/debounce.test.ts
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { debounce } from './debounce';

describe('debounce', () => {
 beforeEach(() => {
 vi.useFakeTimers();
 });

 afterEach(() => {
 vi.useRealTimers();
 });

 it('should debounce function calls', () => {
 const fn = vi.fn();
 const debouncedFn = debounce(fn, 300);

 // 複数回呼び出し
 debouncedFn('a');
 debouncedFn('b');
 debouncedFn('c');

 // まだ呼ばれていない
 expect(fn).not.toHaveBeenCalled();

 // 300ms経過
 vi.advanceTimersByTime(300);

 // 最後の引数で1回だけ呼ばれる
 expect(fn).toHaveBeenCalledTimes(1);
 expect(fn).toHaveBeenCalledWith('c');
 });

 it('should reset timer on new calls', () => {
 const fn = vi.fn();
 const debouncedFn = debounce(fn, 300);

 debouncedFn('a');
 vi.advanceTimersByTime(200);

 debouncedFn('b');
 vi.advanceTimersByTime(200);

 // まだ呼ばれていない(タイマーがリセットされた)
 expect(fn).not.toHaveBeenCalled();

 vi.advanceTimersByTime(100);

 expect(fn).toHaveBeenCalledWith('b');
 });

 it('should work with runAllTimers', () => {
 const fn = vi.fn();
 const debouncedFn = debounce(fn, 1000);

 debouncedFn('test');
 vi.runAllTimers();

 expect(fn).toHaveBeenCalledWith('test');
 });
});

Reactコンポーネントテスト

Testing Libraryの使用

// components/Counter.tsx
import { useState } from 'react';

interface CounterProps {
 initialValue?: number;
 onCountChange?: (count: number) => void;
}

export function Counter({ initialValue = 0, onCountChange }: CounterProps) {
 const [count, setCount] = useState(initialValue);

 const handleIncrement = () => {
 const newCount = count + 1;
 setCount(newCount);
 onCountChange?.(newCount);
 };

 const handleDecrement = () => {
 const newCount = count - 1;
 setCount(newCount);
 onCountChange?.(newCount);
 };

 return (
 <div>
 <span data-testid="count">{count}
 <button onClick={handleDecrement} disabled={count <= 0}>
 -
 </button>
 <button onClick={handleIncrement}>+</button>
 </div>
 );
}
// components/Counter.test.tsx
import { describe, it, expect, vi } from 'vitest';
import { render, screen, fireEvent } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { Counter } from './Counter';

describe('Counter', () => {
 it('should render with initial value', () => {
 render(<Counter initialValue={5} />);

 expect(screen.getByTestId('count')).toHaveTextContent('5');
 });

 it('should render with default value 0', () => {
 render(<Counter />);

 expect(screen.getByTestId('count')).toHaveTextContent('0');
 });

 it('should increment count on + button click', async () => {
 const user = userEvent.setup();
 render(<Counter />);

 await user.click(screen.getByRole('button', { name: '+' }));

 expect(screen.getByTestId('count')).toHaveTextContent('1');
 });

 it('should decrement count on - button click', async () => {
 const user = userEvent.setup();
 render(<Counter initialValue={5} />);

 await user.click(screen.getByRole('button', { name: '-' }));

 expect(screen.getByTestId('count')).toHaveTextContent('4');
 });

 it('should disable decrement button at 0', () => {
 render(<Counter initialValue={0} />);

 expect(screen.getByRole('button', { name: '-' })).toBeDisabled();
 });

 it('should call onCountChange callback', async () => {
 const onCountChange = vi.fn();
 const user = userEvent.setup();

 render(<Counter onCountChange={onCountChange} />);

 await user.click(screen.getByRole('button', { name: '+' }));

 expect(onCountChange).toHaveBeenCalledWith(1);
 });

 it('should handle multiple clicks', async () => {
 const user = userEvent.setup();
 render(<Counter />);

 const incrementButton = screen.getByRole('button', { name: '+' });

 await user.click(incrementButton);
 await user.click(incrementButton);
 await user.click(incrementButton);

 expect(screen.getByTestId('count')).toHaveTextContent('3');
 });
});

非同期コンポーネントのテスト

// components/UserProfile.tsx
import { useState, useEffect } from 'react';

interface User {
 id: string;
 name: string;
 email: string;
}

interface UserProfileProps {
 userId: string;
}

export function UserProfile({ userId }: UserProfileProps) {
 const [user, setUser] = useState<User | null>(null);
 const [loading, setLoading] = useState(true);
 const [error, setError] = useState<string | null>(null);

 useEffect(() => {
 async function fetchUser() {
 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>Loading...</div>;
 if (error) return <div role="alert">Error: {error}</div>;
 if (!user) return <div>User not found</div>;

 return (
 <div>
 <h1>{user.name}</h1>
 <p>{user.email}</p>
 </div>
 );
}
// components/UserProfile.test.tsx
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { render, screen, waitFor } from '@testing-library/react';
import { UserProfile } from './UserProfile';

const mockFetch = vi.fn();
global.fetch = mockFetch;

describe('UserProfile', () => {
 beforeEach(() => {
 mockFetch.mockReset();
 });

 it('should show loading state initially', () => {
 mockFetch.mockImplementation(() => new Promise(() => {})); // 永続的にpending

 render(<UserProfile userId="1" />);

 expect(screen.getByText('Loading...')).toBeInTheDocument();
 });

 it('should render user data after loading', async () => {
 const mockUser = { id: '1', name: 'John Doe', email: 'john@example.com' };

 mockFetch.mockResolvedValueOnce({
 ok: true,
 json: () => Promise.resolve(mockUser),
 });

 render(<UserProfile userId="1" />);

 await waitFor(() => {
 expect(screen.getByText('John Doe')).toBeInTheDocument();
 });

 expect(screen.getByText('john@example.com')).toBeInTheDocument();
 });

 it('should show error message on fetch failure', async () => {
 mockFetch.mockResolvedValueOnce({
 ok: false,
 status: 404,
 });

 render(<UserProfile userId="999" />);

 await waitFor(() => {
 expect(screen.getByRole('alert')).toHaveTextContent('Error: Failed to fetch user');
 });
 });

 it('should refetch when userId changes', async () => {
 const user1 = { id: '1', name: 'User 1', email: 'user1@example.com' };
 const user2 = { id: '2', name: 'User 2', email: 'user2@example.com' };

 mockFetch
 .mockResolvedValueOnce({ ok: true, json: () => Promise.resolve(user1) })
 .mockResolvedValueOnce({ ok: true, json: () => Promise.resolve(user2) });

 const { rerender } = render(<UserProfile userId="1" />);

 await waitFor(() => {
 expect(screen.getByText('User 1')).toBeInTheDocument();
 });

 rerender(<UserProfile userId="2" />);

 await waitFor(() => {
 expect(screen.getByText('User 2')).toBeInTheDocument();
 });
 });
});

スナップショットテスト

// components/Card.test.tsx
import { describe, it, expect } from 'vitest';
import { render } from '@testing-library/react';
import { Card } from './Card';

describe('Card', () => {
 it('should match snapshot', () => {
 const { container } = render(
 <Card title="Test Title" description="Test description" />
 );

 expect(container).toMatchSnapshot();
 });

 it('should match inline snapshot', () => {
 const { container } = render(
 <Card title="Inline" description="Test" />
 );

 expect(container.innerHTML).toMatchInlineSnapshot(`
 "<div class="card"><h2>Inline</h2><p>Test</p></div>"
 `);
 });
});

テストカバレッジ

カバレッジレポート

# カバレッジを含めてテスト実行
npx vitest run --coverage

# UI付きでカバレッジ確認
npx vitest --coverage --ui
// vitest.config.ts - カバレッジ詳細設定
export default defineConfig({
 test: {
 coverage: {
 provider: 'v8',
 reporter: ['text', 'json', 'html', 'lcov'],
 reportsDirectory: './coverage',
 include: ['src/**/*.{ts,tsx}'],
 exclude: [
 'node_modules/',
 'src/test/',
 '**/*.d.ts',
 '**/*.config.*',
 '**/types/*',
 '**/__mocks__/*',
 ],
 all: true, // テストされていないファイルも含める
 thresholds: {
 global: {
 branches: 80,
 functions: 80,
 lines: 80,
 statements: 80,
 },
 // ファイル単位の閾値
 'src/utils/**/*.ts': {
 branches: 90,
 functions: 90,
 },
 },
 },
 },
});

CI/CD統合

GitHub Actions

# .github/workflows/test.yml
name: Test

on:
 push:
 branches: [main]
 pull_request:
 branches: [main]

jobs:
 test:
 runs-on: ubuntu-latest

 steps:
 - uses: actions/checkout@v4

 - name: Setup Node.js
 uses: actions/setup-node@v4
 with:
 node-version: '20'
 cache: 'npm'

 - name: Install dependencies
 run: npm ci

 - name: Run tests
 run: npm test -- --coverage

 - name: Upload coverage to Codecov
 uses: codecov/codecov-action@v4
 with:
 files: ./coverage/lcov.info
 fail_ci_if_error: true

動作確認

テスト環境を作ったら、次を確認します。

確認項目コマンド例OKの状態
単体実行npx vitest runテストが終了コード0で完了する
監視実行npx vitestファイル変更で再実行される
カバレッジnpx vitest run --coveragecoverageディレクトリが生成される
CInpm test -- --run対話なしで実行できる

失敗した時は、実装ミス、期待値ミス、テストデータミス、非同期処理の待ち忘れを分けて確認します。

練習

次の順番でテストを書いてください。

  1. add(a, b) の正常系を書く
  2. 空文字や不正値など境界値のテストを追加する
  3. 外部API呼び出しを vi.mock で置き換える
  4. 失敗するテストを1つ作り、エラーメッセージを読む
  5. GitHub Actionsで vitest run を実行する

次のステップ

テストの考え方を先に固めたい場合は TDD入門テスト失敗の切り分け を確認してください。ブラウザ操作まで含める場合は Playwright E2Eテスト実践ガイド が次の候補です。

まとめ

実践メモ: vitest --uiでブラウザベースのUIモードが起動し、テスト結果をインタラクティブに確認できます。デバッグ時に便利です。

注意: vi.mock()はファイルの先頭に巻き上げられます。モック対象のモジュールと実際のインポートの順序に注意してください

Vitestは、モダンなJavaScript/TypeScriptプロジェクトに最適なテストフレームワークです。

テスト戦略のベストプラクティス

レベル目的比率
ユニットテスト個別関数/クラス70%
統合テストコンポーネント連携20%
E2Eテストユーザーフロー10%

主要なポイント

  1. 高速なフィードバック: HMRとウォッチモードで即時確認
  2. Jest互換API: 既存の知識を活用
  3. TypeScriptネイティブ: 設定不要で型安全
  4. 豊富なモック機能: vi.mock/vi.spyOn
  5. 並列実行: スレッドによる高速化

Vitestを活用することで、高品質なテストを効率的に実装できます。

参考リンク

参考リソース

← 一覧に戻る
PR
PR
PR
PR