最終更新:
Vitest高度テスティング: モック・スナップショット・並列実行の完全ガイド
Vitest高度テスティング: モック・スナップショット・並列実行の完全ガイド
Vitestは、Viteベースの高速なテストフレームワークです。この記事では、モッキング、スナップショットテスト、並列実行など、Vitestの高度な機能を活用した実践的なテスティング手法を解説します。
Vitestのセットアップ
インストールと基本設定
# Vitestとテストユーティリティのインストール
npm install -D vitest @vitest/ui @vitest/coverage-v8
npm install -D @testing-library/react @testing-library/jest-dom
npm install -D happy-dom # またはjsdom
// vitest.config.ts
import { defineConfig } from 'vitest/config';
import react from '@vitejs/plugin-react';
export default defineConfig({
plugins: [react()],
test: {
globals: true,
environment: 'happy-dom',
setupFiles: './src/test/setup.ts',
coverage: {
provider: 'v8',
reporter: ['text', 'json', 'html'],
exclude: [
'node_modules/',
'src/test/',
'**/*.d.ts',
'**/*.config.*',
'**/mockData.ts',
],
},
// 並列実行の設定
threads: true,
maxThreads: 4,
minThreads: 2,
// タイムアウト設定
testTimeout: 10000,
hookTimeout: 10000,
},
});
// src/test/setup.ts
import { expect, afterEach } from 'vitest';
import { cleanup } from '@testing-library/react';
import matchers from '@testing-library/jest-dom/matchers';
// カスタムマッチャーの追加
expect.extend(matchers);
// 各テスト後にクリーンアップ
afterEach(() => {
cleanup();
});
モッキングの完全ガイド
1. 関数のモック
// src/utils/api.ts
export async function fetchUser(id: string) {
const response = await fetch(`https://api.example.com/users/${id}`);
return response.json();
}
export async function updateUser(id: string, data: Record<string, any>) {
const response = await fetch(`https://api.example.com/users/${id}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data),
});
return response.json();
}
// src/utils/api.test.ts
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { fetchUser, updateUser } from './api';
// グローバルなfetchをモック
global.fetch = vi.fn();
describe('API Functions', () => {
beforeEach(() => {
// 各テスト前にモックをリセット
vi.clearAllMocks();
});
afterEach(() => {
// すべてのモックを復元
vi.restoreAllMocks();
});
describe('fetchUser', () => {
it('should fetch user data', async () => {
const mockUser = { id: '1', name: 'Alice', email: 'alice@example.com' };
// fetchのモック実装
(global.fetch as any).mockResolvedValueOnce({
json: async () => mockUser,
});
const user = await fetchUser('1');
expect(global.fetch).toHaveBeenCalledWith('https://api.example.com/users/1');
expect(user).toEqual(mockUser);
});
it('should handle fetch errors', async () => {
// エラーをスロー
(global.fetch as any).mockRejectedValueOnce(new Error('Network error'));
await expect(fetchUser('1')).rejects.toThrow('Network error');
});
});
describe('updateUser', () => {
it('should update user data', async () => {
const updateData = { name: 'Alice Updated' };
const mockResponse = { id: '1', ...updateData };
(global.fetch as any).mockResolvedValueOnce({
json: async () => mockResponse,
});
const result = await updateUser('1', updateData);
expect(global.fetch).toHaveBeenCalledWith(
'https://api.example.com/users/1',
{
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(updateData),
}
);
expect(result).toEqual(mockResponse);
});
});
});
2. モジュール全体のモック
// src/services/logger.ts
export class Logger {
info(message: string) {
console.log(`[INFO] ${message}`);
}
error(message: string, error?: Error) {
console.error(`[ERROR] ${message}`, error);
}
}
export const logger = new Logger();
// src/services/userService.ts
import { logger } from './logger';
import { fetchUser } from '../utils/api';
export async function getUserById(id: string) {
logger.info(`Fetching user ${id}`);
try {
const user = await fetchUser(id);
logger.info(`User ${id} fetched successfully`);
return user;
} catch (error) {
logger.error(`Failed to fetch user ${id}`, error as Error);
throw error;
}
}
// src/services/userService.test.ts
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { getUserById } from './userService';
// モジュール全体をモック
vi.mock('./logger', () => ({
logger: {
info: vi.fn(),
error: vi.fn(),
},
}));
vi.mock('../utils/api', () => ({
fetchUser: vi.fn(),
}));
import { logger } from './logger';
import { fetchUser } from '../utils/api';
describe('UserService', () => {
beforeEach(() => {
vi.clearAllMocks();
});
describe('getUserById', () => {
it('should log and fetch user', async () => {
const mockUser = { id: '1', name: 'Alice' };
(fetchUser as any).mockResolvedValueOnce(mockUser);
const user = await getUserById('1');
expect(logger.info).toHaveBeenCalledWith('Fetching user 1');
expect(fetchUser).toHaveBeenCalledWith('1');
expect(logger.info).toHaveBeenCalledWith('User 1 fetched successfully');
expect(user).toEqual(mockUser);
});
it('should log errors', async () => {
const error = new Error('Network error');
(fetchUser as any).mockRejectedValueOnce(error);
await expect(getUserById('1')).rejects.toThrow('Network error');
expect(logger.error).toHaveBeenCalledWith('Failed to fetch user 1', error);
});
});
});
3. スパイ(Spy)の活用
// src/utils/storage.ts
export class Storage {
private data: Map<string, any> = new Map();
set(key: string, value: any): void {
this.data.set(key, value);
this.notify('set', key, value);
}
get(key: string): any {
const value = this.data.get(key);
this.notify('get', key, value);
return value;
}
delete(key: string): boolean {
const result = this.data.delete(key);
this.notify('delete', key);
return result;
}
private notify(action: string, key: string, value?: any): void {
console.log(`Storage ${action}: ${key}`, value);
}
}
// src/utils/storage.test.ts
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { Storage } from './storage';
describe('Storage', () => {
let storage: Storage;
beforeEach(() => {
storage = new Storage();
});
it('should notify on set', () => {
// privateメソッドnotifyにスパイを設定
const notifySpy = vi.spyOn(storage as any, 'notify');
storage.set('key1', 'value1');
expect(notifySpy).toHaveBeenCalledWith('set', 'key1', 'value1');
expect(notifySpy).toHaveBeenCalledTimes(1);
});
it('should notify on get', () => {
const notifySpy = vi.spyOn(storage as any, 'notify');
storage.set('key1', 'value1');
notifySpy.mockClear(); // setでの呼び出しをクリア
const value = storage.get('key1');
expect(value).toBe('value1');
expect(notifySpy).toHaveBeenCalledWith('get', 'key1', 'value1');
});
it('should notify on delete', () => {
const notifySpy = vi.spyOn(storage as any, 'notify');
storage.set('key1', 'value1');
notifySpy.mockClear();
const result = storage.delete('key1');
expect(result).toBe(true);
expect(notifySpy).toHaveBeenCalledWith('delete', 'key1');
});
});
スナップショットテスト
1. オブジェクトのスナップショット
// src/utils/formatter.ts
export function formatUser(user: {
id: string;
name: string;
email: string;
createdAt: Date;
}) {
return {
id: user.id,
name: user.name,
email: user.email,
displayName: `${user.name} <${user.email}>`,
createdAt: user.createdAt.toISOString(),
age: Math.floor((Date.now() - user.createdAt.getTime()) / (1000 * 60 * 60 * 24)),
};
}
// src/utils/formatter.test.ts
import { describe, it, expect, beforeAll, vi } from 'vitest';
import { formatUser } from './formatter';
describe('formatUser', () => {
beforeAll(() => {
// 現在時刻を固定してテストの再現性を確保
vi.setSystemTime(new Date('2025-05-25T00:00:00Z'));
});
it('should format user correctly', () => {
const user = {
id: '1',
name: 'Alice',
email: 'alice@example.com',
createdAt: new Date('2025-01-01T00:00:00Z'),
};
const formatted = formatUser(user);
// スナップショットと比較
expect(formatted).toMatchSnapshot();
});
it('should handle inline snapshots', () => {
const user = {
id: '2',
name: 'Bob',
email: 'bob@example.com',
createdAt: new Date('2025-05-01T00:00:00Z'),
};
const formatted = formatUser(user);
// インラインスナップショット
expect(formatted).toMatchInlineSnapshot(`
{
"age": 144,
"createdAt": "2025-01-01T00:00:00.000Z",
"displayName": "Alice <alice@example.com>",
"email": "alice@example.com",
"id": "1",
"name": "Alice",
}
`);
});
});
2. Reactコンポーネントのスナップショット
// src/components/UserCard.tsx
interface UserCardProps {
user: {
id: string;
name: string;
email: string;
role: 'admin' | 'user';
};
onEdit?: () => void;
onDelete?: () => void;
}
export function UserCard({ user, onEdit, onDelete }: UserCardProps) {
return (
<div className="user-card">
<div className="user-card__header">
<h3>{user.name}</h3>
<span className={`badge badge--${user.role}`}>{user.role}</span>
</div>
<div className="user-card__body">
<p>{user.email}</p>
</div>
<div className="user-card__actions">
{onEdit && <button onClick={onEdit}>Edit</button>}
{onDelete && <button onClick={onDelete}>Delete</button>}
</div>
</div>
);
}
// src/components/UserCard.test.tsx
import { describe, it, expect } from 'vitest';
import { render } from '@testing-library/react';
import { UserCard } from './UserCard';
describe('UserCard', () => {
it('should match snapshot for admin user', () => {
const user = {
id: '1',
name: 'Alice Admin',
email: 'alice@example.com',
role: 'admin' as const,
};
const { container } = render(<UserCard user={user} />);
expect(container.firstChild).toMatchSnapshot();
});
it('should match snapshot with actions', () => {
const user = {
id: '2',
name: 'Bob User',
email: 'bob@example.com',
role: 'user' as const,
};
const { container } = render(
<UserCard user={user} onEdit={() => {}} onDelete={() => {}} />
);
expect(container.firstChild).toMatchSnapshot();
});
});
並列実行とパフォーマンス最適化
1. テストの並列実行制御
// src/tests/performance.test.ts
import { describe, it, expect } from 'vitest';
// 並列実行(デフォルト)
describe('Parallel Tests', () => {
it('test 1', async () => {
await new Promise(resolve => setTimeout(resolve, 100));
expect(true).toBe(true);
});
it('test 2', async () => {
await new Promise(resolve => setTimeout(resolve, 100));
expect(true).toBe(true);
});
});
// 順次実行
describe.sequential('Sequential Tests', () => {
it('test 1 must run first', async () => {
await new Promise(resolve => setTimeout(resolve, 100));
expect(true).toBe(true);
});
it('test 2 runs after test 1', async () => {
await new Promise(resolve => setTimeout(resolve, 100));
expect(true).toBe(true);
});
});
// 並行実行(同時実行数の制御)
describe.concurrent('Concurrent Tests', () => {
it('concurrent test 1', async () => {
await new Promise(resolve => setTimeout(resolve, 100));
expect(true).toBe(true);
});
it('concurrent test 2', async () => {
await new Promise(resolve => setTimeout(resolve, 100));
expect(true).toBe(true);
});
});
2. テストのシャーディング
// package.json
{
"scripts": {
"test": "vitest",
"test:shard1": "vitest --shard=1/3",
"test:shard2": "vitest --shard=2/3",
"test:shard3": "vitest --shard=3/3",
"test:ci": "npm run test:shard1 & npm run test:shard2 & npm run test:shard3"
}
}
カバレッジとレポート
カバレッジの設定と実行
# カバレッジ付きでテスト実行
npm run test -- --coverage
# カバレッジしきい値を設定
npm run test -- --coverage --coverage.lines=80 --coverage.functions=80
// vitest.config.ts
export default defineConfig({
test: {
coverage: {
provider: 'v8',
reporter: ['text', 'json', 'html', 'lcov'],
exclude: [
'node_modules/',
'src/test/',
'**/*.d.ts',
'**/*.config.*',
'**/mockData.ts',
'src/main.tsx',
],
thresholds: {
lines: 80,
functions: 80,
branches: 80,
statements: 80,
},
},
},
});
UIモードでのテスト実行
# UIモードで実行
npm run test -- --ui
# watchモードとUIモードを併用
npm run test -- --ui --watch
まとめ
Vitestの高度な機能を活用することで、効率的で保守性の高いテストコードを書くことができます。主なポイントは以下の通りです。
- モッキング: 外部依存を完全にコントロールし、テストの独立性を確保
- スナップショット: UIコンポーネントやデータ構造の変更を簡単に検知
- 並列実行: テストの実行速度を大幅に改善
- カバレッジ: コード品質を定量的に評価
これらの手法を組み合わせることで、信頼性の高いアプリケーション開発が実現できます。