Vitestは、Viteベースの超高速なテストフレームワークです。Jest互換のAPIを持ちながら、ESMネイティブサポートとHMR(Hot Module Replacement)による高速な開発体験を提供します。
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}</span>
<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
まとめ
Vitestは、モダンなJavaScript/TypeScriptプロジェクトに最適なテストフレームワークです。
テスト戦略のベストプラクティス
| レベル | 目的 | 比率 |
|---|---|---|
| ユニットテスト | 個別関数/クラス | 70% |
| 統合テスト | コンポーネント連携 | 20% |
| E2Eテスト | ユーザーフロー | 10% |
主要なポイント
- 高速なフィードバック: HMRとウォッチモードで即時確認
- Jest互換API: 既存の知識を活用
- TypeScriptネイティブ: 設定不要で型安全
- 豊富なモック機能: vi.mock/vi.spyOn
- 並列実行: スレッドによる高速化
Vitestを活用することで、高品質なテストを効率的に実装できます。