test-tube

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

2025.12.02

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%

主要なポイント

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

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

参考リンク

← 一覧に戻る