テスト戦略設計パターン - テストピラミッドから実践的なテスト設計まで

2025.12.02

テスト戦略の重要性

ソフトウェアの品質を担保するためには、適切なテスト戦略が不可欠です。効率的なテスト設計は、開発速度と品質のバランスを取りながら、持続可能な開発を可能にします。

flowchart TB
    subgraph Purpose["テストの目的"]
        subgraph QA["1. 品質保証 (Quality Assurance)"]
            QA1["バグの早期発見"]
            QA2["リグレッション防止"]
            QA3["仕様の検証"]
        end
        subgraph Design["2. 設計改善 (Design Improvement)"]
            D1["テスタビリティの向上"]
            D2["疎結合な設計の促進"]
            D3["インターフェースの明確化"]
        end
        subgraph Docs["3. ドキュメンテーション"]
            Doc1["実行可能な仕様書"]
            Doc2["使用例の提示"]
            Doc3["境界条件の明示"]
        end
    end

テストピラミッド

flowchart TB
    subgraph Pyramid["テストピラミッド"]
        E2E["E2E Tests<br/>(少数・高コスト)"]
        Integration["Integration Tests<br/>(中程度)"]
        Unit["Unit Tests<br/>(多数・低コスト)"]

        E2E --> Integration --> Unit
    end

特性:

レベル実行速度信頼性コスト
E2E遅い低い高い
Integration中程度中程度中程度
Unit速い高い低い

ユニットテスト

基本原則(F.I.R.S.T)

// F.I.R.S.T原則に従ったユニットテスト

// Fast(高速)- テストは素早く実行できること
// Independent(独立)- テスト間に依存関係がないこと
// Repeatable(再現可能)- 何度実行しても同じ結果になること
// Self-Validating(自己検証)- 成功/失敗が明確であること
// Timely(適時)- プロダクションコードの前後に書くこと

import { describe, it, expect, beforeEach } from 'vitest';

// テスト対象のユーティリティ関数
function calculateTax(price: number, taxRate: number): number {
  if (price < 0) throw new Error('Price cannot be negative');
  if (taxRate < 0 || taxRate > 1) throw new Error('Invalid tax rate');
  return Math.round(price * taxRate);
}

describe('calculateTax', () => {
  // 正常系
  it('should calculate tax correctly', () => {
    expect(calculateTax(1000, 0.1)).toBe(100);
  });

  it('should round to nearest integer', () => {
    expect(calculateTax(999, 0.1)).toBe(100); // 99.9 → 100
  });

  // 境界値テスト
  it('should return 0 for zero price', () => {
    expect(calculateTax(0, 0.1)).toBe(0);
  });

  it('should handle zero tax rate', () => {
    expect(calculateTax(1000, 0)).toBe(0);
  });

  it('should handle 100% tax rate', () => {
    expect(calculateTax(1000, 1)).toBe(1000);
  });

  // 異常系
  it('should throw for negative price', () => {
    expect(() => calculateTax(-100, 0.1)).toThrow('Price cannot be negative');
  });

  it('should throw for invalid tax rate', () => {
    expect(() => calculateTax(1000, 1.5)).toThrow('Invalid tax rate');
    expect(() => calculateTax(1000, -0.1)).toThrow('Invalid tax rate');
  });
});

AAAパターン

// Arrange-Act-Assert パターン

import { describe, it, expect } from 'vitest';

class ShoppingCart {
  private items: Array<{ name: string; price: number; quantity: number }> = [];

  addItem(name: string, price: number, quantity: number = 1): void {
    this.items.push({ name, price, quantity });
  }

  removeItem(name: string): void {
    this.items = this.items.filter(item => item.name !== name);
  }

  getTotal(): number {
    return this.items.reduce((sum, item) => sum + item.price * item.quantity, 0);
  }

  getItemCount(): number {
    return this.items.reduce((sum, item) => sum + item.quantity, 0);
  }
}

describe('ShoppingCart', () => {
  it('should calculate total correctly', () => {
    // Arrange(準備)
    const cart = new ShoppingCart();
    cart.addItem('Apple', 100, 3);
    cart.addItem('Banana', 80, 2);

    // Act(実行)
    const total = cart.getTotal();

    // Assert(検証)
    expect(total).toBe(460); // 100*3 + 80*2
  });

  it('should remove item correctly', () => {
    // Arrange
    const cart = new ShoppingCart();
    cart.addItem('Apple', 100, 1);
    cart.addItem('Banana', 80, 1);

    // Act
    cart.removeItem('Apple');

    // Assert
    expect(cart.getTotal()).toBe(80);
    expect(cart.getItemCount()).toBe(1);
  });
});

テストダブル

flowchart TB
    subgraph TestDoubles["テストダブルの種類"]
        subgraph Stub["1. Stub(スタブ)"]
            S1["事前定義された値を返す"]
            S2["外部依存をシミュレート"]
            S3["入力に対する出力のみを制御"]
        end
        subgraph Mock["2. Mock(モック)"]
            M1["期待される呼び出しを検証"]
            M2["呼び出し回数・引数を確認"]
            M3["振る舞いの検証に使用"]
        end
        subgraph Spy["3. Spy(スパイ)"]
            Sp1["実際の実装を使いながら呼び出しを記録"]
            Sp2["後から呼び出しを検証可能"]
            Sp3["部分的なモック化"]
        end
        subgraph Fake["4. Fake(フェイク)"]
            F1["実装の簡略版"]
            F2["インメモリDBなど"]
            F3["本番と同様のインターフェース"]
        end
    end

テストダブルの実装例

import { describe, it, expect, vi, beforeEach } from 'vitest';

// 依存関係のインターフェース
interface EmailService {
  send(to: string, subject: string, body: string): Promise<boolean>;
}

interface UserRepository {
  findById(id: string): Promise<User | null>;
  save(user: User): Promise<void>;
}

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

// テスト対象のクラス
class UserService {
  constructor(
    private userRepo: UserRepository,
    private emailService: EmailService
  ) {}

  async notifyUser(userId: string, message: string): Promise<boolean> {
    const user = await this.userRepo.findById(userId);
    if (!user) {
      throw new Error('User not found');
    }

    return this.emailService.send(
      user.email,
      'Notification',
      message
    );
  }
}

describe('UserService', () => {
  let userRepo: UserRepository;
  let emailService: EmailService;
  let userService: UserService;

  beforeEach(() => {
    // Stubの作成
    userRepo = {
      findById: vi.fn(),
      save: vi.fn(),
    };

    // Mockの作成
    emailService = {
      send: vi.fn(),
    };

    userService = new UserService(userRepo, emailService);
  });

  it('should send notification to user', async () => {
    // Stubの設定(事前定義された値を返す)
    const mockUser: User = {
      id: '1',
      email: 'test@example.com',
      name: 'Test User',
    };
    vi.mocked(userRepo.findById).mockResolvedValue(mockUser);
    vi.mocked(emailService.send).mockResolvedValue(true);

    // 実行
    const result = await userService.notifyUser('1', 'Hello!');

    // 結果の検証
    expect(result).toBe(true);

    // Mockの検証(呼び出しを確認)
    expect(emailService.send).toHaveBeenCalledWith(
      'test@example.com',
      'Notification',
      'Hello!'
    );
    expect(emailService.send).toHaveBeenCalledTimes(1);
  });

  it('should throw error when user not found', async () => {
    // Stubの設定(nullを返す)
    vi.mocked(userRepo.findById).mockResolvedValue(null);

    // 例外の検証
    await expect(userService.notifyUser('999', 'Hello!'))
      .rejects.toThrow('User not found');

    // emailServiceが呼ばれていないことを確認
    expect(emailService.send).not.toHaveBeenCalled();
  });
});

// Fakeの例(インメモリリポジトリ)
class FakeUserRepository implements UserRepository {
  private users: Map<string, User> = new Map();

  async findById(id: string): Promise<User | null> {
    return this.users.get(id) ?? null;
  }

  async save(user: User): Promise<void> {
    this.users.set(user.id, user);
  }

  // テスト用のヘルパーメソッド
  seed(users: User[]): void {
    users.forEach(user => this.users.set(user.id, user));
  }

  clear(): void {
    this.users.clear();
  }
}

統合テスト

// APIの統合テスト
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
import { createServer, Server } from 'http';
import { app } from '../src/app';

describe('API Integration Tests', () => {
  let server: Server;
  let baseURL: string;

  beforeAll(async () => {
    server = createServer(app);
    await new Promise<void>(resolve => {
      server.listen(0, () => resolve());
    });
    const address = server.address();
    const port = typeof address === 'object' ? address?.port : 0;
    baseURL = `http://localhost:${port}`;
  });

  afterAll(async () => {
    await new Promise<void>(resolve => server.close(() => resolve()));
  });

  describe('POST /api/users', () => {
    it('should create a new user', async () => {
      const response = await fetch(`${baseURL}/api/users`, {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({
          name: 'Test User',
          email: 'test@example.com',
        }),
      });

      expect(response.status).toBe(201);

      const data = await response.json();
      expect(data).toMatchObject({
        name: 'Test User',
        email: 'test@example.com',
      });
      expect(data.id).toBeDefined();
    });

    it('should return 400 for invalid data', async () => {
      const response = await fetch(`${baseURL}/api/users`, {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({
          name: '', // 空の名前
        }),
      });

      expect(response.status).toBe(400);
    });
  });

  describe('GET /api/users/:id', () => {
    it('should return user by id', async () => {
      // 事前にユーザーを作成
      const createResponse = await fetch(`${baseURL}/api/users`, {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({
          name: 'Get Test',
          email: 'get@example.com',
        }),
      });
      const created = await createResponse.json();

      // 作成したユーザーを取得
      const response = await fetch(`${baseURL}/api/users/${created.id}`);
      expect(response.status).toBe(200);

      const data = await response.json();
      expect(data.id).toBe(created.id);
    });

    it('should return 404 for non-existent user', async () => {
      const response = await fetch(`${baseURL}/api/users/non-existent-id`);
      expect(response.status).toBe(404);
    });
  });
});

TDD(テスト駆動開発)

flowchart TB
    subgraph TDD["TDDサイクル(Red-Green-Refactor)"]
        Red["RED<br/>(失敗)"]
        Green["GREEN<br/>(成功)"]
        Refactor["REFACTOR<br/>(改善)"]

        Red -->|"失敗するテストを書く"| Green
        Green -->|"最小限のコードで通す"| Refactor
        Refactor -->|"コードを改善"| Red
    end

TDD実践例

// Step 1: RED - 失敗するテストを書く
describe('StringCalculator', () => {
  it('should return 0 for empty string', () => {
    const calc = new StringCalculator();
    expect(calc.add('')).toBe(0);
  });
});

// Step 2: GREEN - 最小限のコードで通す
class StringCalculator {
  add(numbers: string): number {
    return 0;
  }
}

// Step 3: 次のテストを追加(RED)
describe('StringCalculator', () => {
  it('should return 0 for empty string', () => {
    const calc = new StringCalculator();
    expect(calc.add('')).toBe(0);
  });

  it('should return number for single number', () => {
    const calc = new StringCalculator();
    expect(calc.add('1')).toBe(1);
    expect(calc.add('5')).toBe(5);
  });
});

// Step 4: GREEN - テストを通す
class StringCalculator {
  add(numbers: string): number {
    if (numbers === '') return 0;
    return parseInt(numbers, 10);
  }
}

// Step 5: さらにテストを追加
describe('StringCalculator', () => {
  // ... 前のテスト

  it('should return sum for multiple numbers', () => {
    const calc = new StringCalculator();
    expect(calc.add('1,2')).toBe(3);
    expect(calc.add('1,2,3')).toBe(6);
  });

  it('should handle newlines as delimiter', () => {
    const calc = new StringCalculator();
    expect(calc.add('1\n2,3')).toBe(6);
  });

  it('should support custom delimiter', () => {
    const calc = new StringCalculator();
    expect(calc.add('//;\n1;2')).toBe(3);
  });

  it('should throw for negative numbers', () => {
    const calc = new StringCalculator();
    expect(() => calc.add('-1,2')).toThrow('Negatives not allowed: -1');
  });
});

// 最終実装
class StringCalculator {
  add(numbers: string): number {
    if (numbers === '') return 0;

    let delimiter = /,|\n/;
    let numString = numbers;

    // カスタムデリミタの処理
    if (numbers.startsWith('//')) {
      const delimiterEnd = numbers.indexOf('\n');
      delimiter = new RegExp(numbers.slice(2, delimiterEnd));
      numString = numbers.slice(delimiterEnd + 1);
    }

    const nums = numString.split(delimiter).map(n => parseInt(n, 10));

    // 負の数チェック
    const negatives = nums.filter(n => n < 0);
    if (negatives.length > 0) {
      throw new Error(`Negatives not allowed: ${negatives.join(', ')}`);
    }

    return nums.reduce((sum, n) => sum + n, 0);
  }
}

カバレッジ戦略

カバレッジの種類

種類説明
ステートメントカバレッジ (Statement)各行が実行されたか
ブランチカバレッジ (Branch)各分岐(if/else)が実行されたか
関数カバレッジ (Function)各関数が呼び出されたか
行カバレッジ (Line)各行が実行されたか

推奨目標:

対象目標
全体80%以上
重要なビジネスロジック90%以上
ユーティリティ関数100%
UIコンポーネント70%以上

注意: カバレッジは品質の指標の一つに過ぎません。100%でもバグは存在しうる。

Vitest カバレッジ設定

// vitest.config.ts
import { defineConfig } from 'vitest/config';

export default defineConfig({
  test: {
    coverage: {
      provider: 'v8',
      reporter: ['text', 'json', 'html'],
      exclude: [
        'node_modules/',
        'tests/',
        '**/*.d.ts',
        '**/*.config.*',
      ],
      thresholds: {
        lines: 80,
        functions: 80,
        branches: 80,
        statements: 80,
      },
    },
  },
});

フロントエンドテスト戦略

// Reactコンポーネントのテスト
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { describe, it, expect, vi } from 'vitest';

// テスト対象のコンポーネント
function LoginForm({ onSubmit }: { onSubmit: (data: { email: string; password: string }) => void }) {
  const handleSubmit = (e: React.FormEvent<HTMLFormElement>) => {
    e.preventDefault();
    const formData = new FormData(e.currentTarget);
    onSubmit({
      email: formData.get('email') as string,
      password: formData.get('password') as string,
    });
  };

  return (
    <form onSubmit={handleSubmit}>
      <label>
        Email
        <input type="email" name="email" required />
      </label>
      <label>
        Password
        <input type="password" name="password" required />
      </label>
      <button type="submit">Login</button>
    </form>
  );
}

describe('LoginForm', () => {
  it('should render form fields', () => {
    render(<LoginForm onSubmit={() => {}} />);

    expect(screen.getByLabelText(/email/i)).toBeInTheDocument();
    expect(screen.getByLabelText(/password/i)).toBeInTheDocument();
    expect(screen.getByRole('button', { name: /login/i })).toBeInTheDocument();
  });

  it('should call onSubmit with form data', async () => {
    const user = userEvent.setup();
    const handleSubmit = vi.fn();

    render(<LoginForm onSubmit={handleSubmit} />);

    await user.type(screen.getByLabelText(/email/i), 'test@example.com');
    await user.type(screen.getByLabelText(/password/i), 'password123');
    await user.click(screen.getByRole('button', { name: /login/i }));

    expect(handleSubmit).toHaveBeenCalledWith({
      email: 'test@example.com',
      password: 'password123',
    });
  });
});

ベストプラクティス

カテゴリベストプラクティス
命名テスト名は「should + 期待される振る舞い」
構造AAAパターン(Arrange-Act-Assert)を使用
独立性テスト間の依存関係を排除
速度ユニットテストは高速に保つ
信頼性フレーキーテストを排除
保守性テストコードもプロダクションコード同様に管理

参考リンク

← 一覧に戻る