T-CREATOR

Vitest テストアーキテクチャ技術:Unit / Integration / Contract の三層設計ガイド

Vitest テストアーキテクチャ技術:Unit / Integration / Contract の三層設計ガイド

テストの保守性と信頼性を高めるには、テストを適切な層に分類して設計することが重要です。Vitest を使った開発では、Unit(単体)、Integration(統合)、Contract(契約)という三層設計を採用することで、テストの目的や範囲を明確にし、効率的なテスト戦略を構築できます。

本記事では、Vitest におけるテスト三層設計の考え方と実装方法を、具体的なコード例と図解を交えて解説します。

背景

テスト設計の重要性

モダンな Web アプリケーション開発では、テストコードの品質がプロダクト全体の品質を左右します。しかし、テストをただ書くだけでは十分ではありません。テストの目的や範囲を明確にし、適切な粒度でテストを設計することが求められます。

Vitest は、Vite エコシステムに最適化された高速なテストフレームワークです。しかし、その高速性を活かすためには、テストの構造を適切に設計する必要があります。

テスト三層設計とは

テスト三層設計は、テストを以下の三つの層に分類する設計手法です。

#対象範囲目的
1Unit(単体)個別の関数やクラスロジックの正確性を検証
2Integration(統合)複数のモジュール間の連携コンポーネント間の協調動作を検証
3Contract(契約)外部 API やサービスとの境界インターフェース仕様の整合性を検証

この三層構造により、テストの責務が明確になり、メンテナンス性が向上します。さらに、テストの実行速度とカバレッジのバランスを最適化できるでしょう。

以下の図は、三層設計におけるテスト範囲の関係性を示しています。

mermaidflowchart TD
    app["アプリケーション全体"]
    app --> unit["Unit テスト層"]
    app --> integration["Integration テスト層"]
    app --> contract["Contract テスト層"]

    unit --> func1["関数 A"]
    unit --> func2["関数 B"]
    unit --> class1["クラス C"]

    integration --> module1["モジュール連携 A-B"]
    integration --> module2["モジュール連携 B-C"]

    contract --> api1["外部 API X"]
    contract --> api2["外部 API Y"]

    style unit fill:#e1f5ff
    style integration fill:#fff4e1
    style contract fill:#ffe1f5

図で理解できる要点

  • Unit テストは最小単位の関数やクラスを対象とする
  • Integration テストは複数モジュール間の連携を検証する
  • Contract テストは外部境界でのインターフェース仕様を保証する

課題

テスト設計における典型的な課題

適切なテスト設計がなされていない場合、以下のような問題が発生します。

テスト範囲の曖昧さ

どのテストが何を検証しているのか不明瞭になり、重複したテストや漏れが発生しやすくなります。特に、Unit テストと Integration テストの境界が曖昧だと、テストの実行時間が無駄に長くなることがあるでしょう。

メンテナンスコストの増大

テストの責務が不明確だと、コード変更時にどのテストを修正すべきか判断できません。結果として、テストの修正に多くの時間を費やすことになります。

外部依存の管理困難

外部 API やデータベースへの依存を適切に管理できないと、テスト実行が不安定になります。ネットワークエラーや外部サービスの変更により、テストが突然失敗することがあるのです。

以下の図は、テスト設計が不適切な場合の問題の連鎖を示しています。

mermaidflowchart LR
    bad_design["テスト設計の不備"]
    bad_design --> overlap["テスト範囲の重複"]
    bad_design --> gap["テストカバレッジの穴"]
    bad_design --> slow["実行速度の低下"]

    overlap --> waste["リソースの無駄"]
    gap --> bug["バグの見逃し"]
    slow --> ci_delay["CI パイプライン遅延"]

    waste --> cost["コスト増大"]
    bug --> quality["品質低下"]
    ci_delay --> productivity["生産性低下"]

    style bad_design fill:#ff6b6b
    style cost fill:#ffd43b
    style quality fill:#ffd43b
    style productivity fill:#ffd43b

図で理解できる要点

  • 不適切なテスト設計は、重複・穴・低速という三つの問題を引き起こす
  • これらの問題は、最終的にコスト・品質・生産性に悪影響を与える

Vitest における課題

Vitest は高速ですが、テスト設計が不適切だとその利点を活かせません。特に、以下の点で注意が必要です。

  • モックの使い方が統一されていない
  • テストファイルの配置ルールが曖昧
  • 外部依存のテスト戦略が不明確

これらの課題を解決するには、明確なテスト設計指針が必要になります。

解決策

テスト三層設計の実装方針

Vitest でテスト三層設計を実装する際は、以下の方針に従います。

層ごとの配置ルール

テストファイルは、各層の責務に応じて明確に分離します。ディレクトリ構造とファイル名の命名規則を統一することで、テストの目的が一目で分かるようになるでしょう。

#ディレクトリファイル名パターン
1Unitsrc​/​__tests__​/​unit​/​*.unit.test.ts
2Integrationsrc​/​__tests__​/​integration​/​*.integration.test.ts
3Contractsrc​/​__tests__​/​contract​/​*.contract.test.ts

層ごとのモック戦略

各層で適切なモック戦略を採用することが重要です。

Unit テスト:すべての外部依存をモック化します。純粋なロジックのみを検証するため、データベースや API などの外部要素は完全に置き換えるのです。

Integration テスト:外部依存は部分的にモック化します。複数モジュールの協調動作を検証するため、実際の連携部分は残しつつ、外部サービスはモック化します。

Contract テスト:外部依存のインターフェースを検証します。実際の API レスポンス構造やエラーパターンを確認し、仕様との整合性を保証するのです。

以下の図は、三層設計における依存関係とモック戦略を示しています。

mermaidflowchart TB
    subgraph unit_layer["Unit テスト層"]
        unit_test["単体テスト"]
        unit_mock["全依存をモック"]
    end

    subgraph integration_layer["Integration テスト層"]
        integration_test["統合テスト"]
        partial_mock["部分的モック"]
    end

    subgraph contract_layer["Contract テスト層"]
        contract_test["契約テスト"]
        interface_mock["インターフェース検証"]
    end

    subgraph app_layer["アプリケーション層"]
        module_a["モジュール A"]
        module_b["モジュール B"]
        external_api["外部 API"]
    end

    unit_test -.->|完全モック| module_a
    unit_test -.->|完全モック| module_b

    integration_test -->|実際の連携| module_a
    integration_test -->|実際の連携| module_b
    integration_test -.->|モック| external_api

    contract_test -.->|仕様検証| external_api

    style unit_layer fill:#e1f5ff
    style integration_layer fill:#fff4e1
    style contract_layer fill:#ffe1f5

図で理解できる要点

  • Unit テストは全依存を完全にモック化
  • Integration テストは内部連携は実際に動かし、外部のみモック化
  • Contract テストは外部 API の仕様適合性を検証

Unit テスト層の設計

単体テストの責務

Unit テストは、個別の関数やクラスのロジックを検証します。この層では、以下の点に注目してテストを設計しましょう。

  • 入力に対する出力の正確性
  • エッジケースやエラーハンドリング
  • 純粋関数としての振る舞い

テスト実行速度の最適化

すべての依存をモック化することで、テスト実行速度を最大化します。Unit テストは頻繁に実行されるため、1 テストあたり数ミリ秒で完了することが理想的です。

Integration テスト層の設計

統合テストの責務

Integration テストは、複数モジュール間の協調動作を検証します。実際のモジュール連携を通じて、以下の点を確認するのです。

  • データの受け渡しが正しく行われるか
  • モジュール間のインターフェースが適切か
  • エラーが適切に伝播するか

実行環境の管理

Integration テストでは、テスト用のデータベースやインメモリストレージを使用します。実際の外部サービスは使わず、制御可能な環境でテストを実行することで、安定性を確保できるでしょう。

Contract テスト層の設計

契約テストの責務

Contract テストは、外部 API やサービスとの境界におけるインターフェース仕様を検証します。以下の点を重点的に確認します。

  • リクエスト/レスポンスの構造が仕様通りか
  • エラーレスポンスの形式が期待通りか
  • API バージョンの互換性が保たれているか

スキーマ検証の実装

Contract テストでは、実際の API レスポンスを JSON Schema や TypeScript の型定義と照合します。これにより、外部サービスの仕様変更を早期に検知できるのです。

以下の図は、Contract テストにおけるスキーマ検証フローを示しています。

mermaidsequenceDiagram
    participant test as Contract テスト
    participant mock as モック API
    participant schema as スキーマ定義
    participant validator as バリデーター

    test->>mock: API リクエスト送信
    mock->>test: レスポンス返却
    test->>schema: スキーマ取得
    test->>validator: レスポンス検証依頼
    validator->>validator: 構造チェック
    validator->>validator: 型チェック
    validator->>validator: 必須フィールド確認

    alt 検証成功
        validator->>test: OK
        test->>test: テスト合格
    else 検証失敗
        validator->>test: エラー詳細
        test->>test: テスト失敗
    end

図で理解できる要点

  • Contract テストは、モック API のレスポンスをスキーマ定義と照合
  • 構造・型・必須フィールドを段階的に検証
  • 不一致があれば詳細なエラー情報を返却

具体例

プロジェクト構成

Vitest でテスト三層設計を実装する際のディレクトリ構成は以下の通りです。

typescriptsrc/
├── __tests__/
│   ├── unit/                    # Unit テスト層
│   │   ├── utils.unit.test.ts
│   │   └── calculator.unit.test.ts
│   ├── integration/             # Integration テスト層
│   │   ├── user-service.integration.test.ts
│   │   └── payment-flow.integration.test.ts
│   └── contract/                # Contract テスト層
│       ├── github-api.contract.test.ts
│       └── payment-api.contract.test.ts
├── services/
│   ├── user-service.ts
│   └── payment-service.ts
└── utils/
    ├── calculator.ts
    └── formatter.ts

このディレクトリ構成により、テストファイルの役割が明確になります。次に、各層の具体的な実装例を見ていきましょう。

Unit テスト層の実装例

対象コード:計算ロジック

まず、テスト対象となるシンプルな計算ロジックを見てみましょう。

typescript// src/utils/calculator.ts

/**
 * 二つの数値を加算する純粋関数
 * @param a 第一引数の数値
 * @param b 第二引数の数値
 * @returns 加算結果
 */
export function add(a: number, b: number): number {
  return a + b;
}

/**
 * 二つの数値を除算する関数
 * @param a 被除数
 * @param b 除数
 * @returns 除算結果
 * @throws Error 除数がゼロの場合
 */
export function divide(a: number, b: number): number {
  if (b === 0) {
    throw new Error('Division by zero is not allowed');
  }
  return a / b;
}

Unit テストの実装

次に、この計算ロジックの Unit テストを実装します。すべての外部依存がないため、モック化は不要です。

typescript// src/__tests__/unit/calculator.unit.test.ts
import { describe, it, expect } from 'vitest';
import { add, divide } from '../../utils/calculator';

describe('Calculator Unit Tests', () => {
  describe('add function', () => {
    // 基本的な加算のテスト
    it('should correctly add two positive numbers', () => {
      const result = add(2, 3);
      expect(result).toBe(5);
    });

    // ゼロを含む加算のテスト
    it('should handle zero values', () => {
      expect(add(0, 5)).toBe(5);
      expect(add(5, 0)).toBe(5);
      expect(add(0, 0)).toBe(0);
    });

続いて、負の数や小数点を含むケースもテストします。

typescript    // 負の数を含む加算のテスト
    it('should handle negative numbers', () => {
      expect(add(-5, 3)).toBe(-2);
      expect(add(-5, -3)).toBe(-8);
    });

    // 小数点を含む加算のテスト
    it('should handle decimal numbers', () => {
      const result = add(0.1, 0.2);
      expect(result).toBeCloseTo(0.3); // 浮動小数点の誤差を考慮
    });
  });

除算関数のテストでは、エラーハンドリングも確認します。

typescript  describe('divide function', () => {
    // 基本的な除算のテスト
    it('should correctly divide two numbers', () => {
      expect(divide(10, 2)).toBe(5);
      expect(divide(9, 3)).toBe(3);
    });

    // ゼロ除算のエラーハンドリングテスト
    it('should throw error when dividing by zero', () => {
      expect(() => divide(10, 0)).toThrow('Division by zero is not allowed');
    });

    // 小数点を含む除算のテスト
    it('should handle decimal division', () => {
      expect(divide(1, 3)).toBeCloseTo(0.333, 2);
    });
  });
});

Unit テストのポイント

  • 外部依存がないため、モック化は不要
  • 入力のパターン(正の数、負の数、ゼロ、小数点)を網羅
  • エラーケースも確実にテスト

Integration テスト層の実装例

対象コード:ユーザーサービス

複数のモジュールが連携するユーザーサービスを見ていきましょう。

typescript// src/services/user-service.ts
import { database } from './database';
import { notificationService } from './notification-service';

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

/**
 * ユーザーを作成し、通知を送信する
 * @param name ユーザー名
 * @param email メールアドレス
 * @returns 作成されたユーザー
 */
export async function createUser(
  name: string,
  email: string
): Promise<User> {
  // データベースにユーザーを保存
  const user = await database.insertUser({ name, email });

  // 通知サービスを呼び出し
  await notificationService.sendWelcomeEmail(email);

  return user;
}

Integration テストの実装

Integration テストでは、実際のモジュール連携を検証しますが、外部サービス(メール送信など)はモック化します。

typescript// src/__tests__/integration/user-service.integration.test.ts
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
import { createUser } from '../../services/user-service';
import { database } from '../../services/database';
import { notificationService } from '../../services/notification-service';

describe('User Service Integration Tests', () => {
  beforeEach(async () => {
    // テスト用データベースをクリア
    await database.clear();

    // 外部通知サービスをモック化
    vi.spyOn(notificationService, 'sendWelcomeEmail')
      .mockResolvedValue(undefined);
  });

  afterEach(() => {
    // モックをリセット
    vi.restoreAllMocks();
  });

実際のテストケースでは、データベースとの連携を含めて検証します。

typescriptit('should create user and send welcome email', async () => {
  // ユーザー作成を実行
  const user = await createUser(
    'John Doe',
    'john@example.com'
  );

  // データベースに保存されたことを確認
  expect(user.id).toBeDefined();
  expect(user.name).toBe('John Doe');
  expect(user.email).toBe('john@example.com');

  // データベースから実際に取得できることを確認
  const savedUser = await database.findUserById(user.id);
  expect(savedUser).toEqual(user);

  // 通知サービスが呼ばれたことを確認
  expect(
    notificationService.sendWelcomeEmail
  ).toHaveBeenCalledWith('john@example.com');
  expect(
    notificationService.sendWelcomeEmail
  ).toHaveBeenCalledTimes(1);
});

エラーケースもテストします。データベースエラー時の挙動を確認しましょう。

typescript  it('should handle database errors gracefully', async () => {
    // データベースのエラーをシミュレート
    vi.spyOn(database, 'insertUser')
      .mockRejectedValue(new Error('Database connection failed'));

    // エラーが適切に伝播することを確認
    await expect(
      createUser('Jane Doe', 'jane@example.com')
    ).rejects.toThrow('Database connection failed');

    // 通知サービスが呼ばれていないことを確認
    expect(notificationService.sendWelcomeEmail)
      .not.toHaveBeenCalled();
  });
});

Integration テストのポイント

  • データベースとの実際の連携を検証
  • 外部通知サービスはモック化して制御
  • エラー伝播も含めて統合動作を確認

以下の図は、Integration テストにおけるモジュール連携フローを示しています。

mermaidflowchart TD
    test_start["Integration テスト開始"]
    test_start --> setup["beforeEach: DB クリア<br/>通知サービスをモック化"]
    setup --> call_service["createUser 呼び出し"]

    call_service --> db_insert["database.insertUser<br/>(実際の DB 操作)"]
    db_insert --> notify["notificationService.sendWelcomeEmail<br/>(モック)"]
    notify --> return_user["ユーザーオブジェクト返却"]

    return_user --> verify_db["DB から実際にユーザー取得"]
    verify_db --> verify_mock["モックの呼び出し回数確認"]
    verify_mock --> cleanup["afterEach: モックリセット"]

    style setup fill:#e1f5ff
    style db_insert fill:#c3fae8
    style notify fill:#ffe066
    style verify_mock fill:#ffd8a8

図で理解できる要点

  • Integration テストでは実際の DB 操作と、モック化した通知サービスを組み合わせる
  • セットアップとクリーンアップを確実に実行
  • 実際の連携動作と、モックの呼び出し状況の両方を検証

Contract テスト層の実装例

対象コード:外部 API クライアント

GitHub API を呼び出すクライアントコードを見てみましょう。

typescript// src/services/github-api-client.ts

export interface GitHubUser {
  login: string;
  id: number;
  avatar_url: string;
  name: string;
  email: string | null;
}

/**
 * GitHub API からユーザー情報を取得
 * @param username GitHub ユーザー名
 * @returns ユーザー情報
 */
export async function fetchGitHubUser(
  username: string
): Promise<GitHubUser> {
  const response = await fetch(
    `https://api.github.com/users/${username}`
  );

  if (!response.ok) {
    throw new Error(`GitHub API error: ${response.status}`);
  }

  return response.json();
}

スキーマ定義

Contract テストでは、API レスポンスの構造を定義します。

typescript// src/__tests__/contract/schemas/github-user.schema.ts

/**
 * GitHub User API のレスポンススキーマ
 * 公式ドキュメント: https://docs.github.com/en/rest/users/users
 */
export const githubUserSchema = {
  type: 'object',
  required: ['login', 'id', 'avatar_url'],
  properties: {
    login: { type: 'string' },
    id: { type: 'number' },
    avatar_url: { type: 'string', format: 'uri' },
    name: { type: ['string', 'null'] },
    email: { type: ['string', 'null'] },
  },
} as const;

Contract テストの実装

Contract テストでは、実際の API レスポンスがスキーマに適合することを検証します。

typescript// src/__tests__/contract/github-api.contract.test.ts
import { describe, it, expect, beforeAll } from 'vitest';
import { fetchGitHubUser } from '../../services/github-api-client';
import Ajv from 'ajv';
import addFormats from 'ajv-formats';
import { githubUserSchema } from './schemas/github-user.schema';

describe('GitHub API Contract Tests', () => {
  let ajv: Ajv;

  beforeAll(() => {
    // JSON Schema バリデーターを初期化
    ajv = new Ajv();
    addFormats(ajv);
  });

実際の API レスポンスの構造を検証するテストです。

typescriptit('should return user data matching the schema', async () => {
  // 実際の GitHub API を呼び出し
  const user = await fetchGitHubUser('octocat');

  // レスポンスがスキーマに適合することを検証
  const validate = ajv.compile(githubUserSchema);
  const isValid = validate(user);

  if (!isValid) {
    console.error(
      'Schema validation errors:',
      validate.errors
    );
  }

  expect(isValid).toBe(true);
});

必須フィールドの存在も確認します。

typescriptit('should include all required fields', async () => {
  const user = await fetchGitHubUser('octocat');

  // 必須フィールドの存在を確認
  expect(user.login).toBeDefined();
  expect(user.id).toBeDefined();
  expect(user.avatar_url).toBeDefined();

  // 型の正確性を確認
  expect(typeof user.login).toBe('string');
  expect(typeof user.id).toBe('number');
  expect(typeof user.avatar_url).toBe('string');
});

エラーレスポンスの形式も検証します。

typescript  it('should handle API errors with proper status codes', async () => {
    // 存在しないユーザーを指定
    await expect(
      fetchGitHubUser('this-user-definitely-does-not-exist-12345')
    ).rejects.toThrow('GitHub API error: 404');
  });
});

Contract テストのポイント

  • 実際の API レスポンス構造を JSON Schema で検証
  • 必須フィールドと型の整合性を確認
  • エラーレスポンスの形式も検証対象

Vitest 設定ファイル

三層設計を効率的に運用するための Vitest 設定を見ていきましょう。

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

export default defineConfig({
  test: {
    // グローバルなテスト設定
    globals: true,
    environment: 'node',

    // カバレッジ設定
    coverage: {
      provider: 'v8',
      reporter: ['text', 'json', 'html'],
      include: ['src/**/*.ts'],
      exclude: [
        'src/__tests__/**',
        'src/**/*.test.ts',
        'src/**/*.spec.ts',
      ],
    },

層ごとのテスト実行を制御する設定です。

typescript    // テストのタイムアウト設定(層ごとに異なる値を推奨)
    testTimeout: 10000, // デフォルト 10 秒

    // 並列実行の設定
    threads: true,
    maxThreads: 4,
    minThreads: 1,
  },
});

package.json のスクリプト設定

層ごとにテストを実行できるようスクリプトを定義します。

json{
  "scripts": {
    "test": "vitest",
    "test:unit": "vitest run src/__tests__/unit",
    "test:integration": "vitest run src/__tests__/integration",
    "test:contract": "vitest run src/__tests__/contract",
    "test:all": "vitest run",
    "test:coverage": "vitest run --coverage"
  }
}

これにより、以下のようにテストを層別に実行できます。

bash# Unit テストのみ実行(最も高速)
yarn test:unit

# Integration テストのみ実行
yarn test:integration

# Contract テストのみ実行(外部 API 呼び出しを含む)
yarn test:contract

# すべてのテストを実行
yarn test:all

設定のポイント

  • 層ごとに独立してテストを実行可能
  • カバレッジ計測でテストの網羅性を確認
  • 並列実行で高速化を実現

以下の図は、テスト実行フローとコマンドの関係を示しています。

mermaidflowchart LR
    dev["開発者"]

    dev -->|yarn test:unit| unit_run["Unit テスト実行<br/>(高速・頻繁)"]
    dev -->|yarn test:integration| integration_run["Integration テスト実行<br/>(中速・定期的)"]
    dev -->|yarn test:contract| contract_run["Contract テスト実行<br/>(低速・重要変更時)"]
    dev -->|yarn test:all| all_run["全テスト実行<br/>(CI パイプライン)"]

    unit_run --> result_unit["結果: 数秒"]
    integration_run --> result_integration["結果: 数十秒"]
    contract_run --> result_contract["結果: 数分"]
    all_run --> result_all["結果: 全層統合"]

    style unit_run fill:#e1f5ff
    style integration_run fill:#fff4e1
    style contract_run fill:#ffe1f5
    style all_run fill:#d0f0d0

図で理解できる要点

  • 開発時は高速な Unit テストを頻繁に実行
  • Integration テストは定期的に実行して連携を確認
  • Contract テストは外部 API の変更時や重要な変更前に実行
  • CI では全層を統合して実行

まとめ

本記事では、Vitest を使ったテスト三層設計の実装方法を解説しました。Unit・Integration・Contract という三つの層を明確に分離することで、テストの保守性と信頼性が大幅に向上します。

三層設計の利点

Unit テスト層では、純粋なロジックを高速に検証できます。すべての外部依存をモック化することで、開発中に何度でも実行できる軽量なテストを実現できるでしょう。

Integration テスト層では、複数モジュールの協調動作を確認します。実際の連携部分は残しつつ、外部サービスはモック化することで、安定したテスト環境を構築できます。

Contract テスト層では、外部 API との境界でインターフェース仕様を保証します。スキーマ検証により、外部サービスの変更を早期に検知できるのです。

実装のポイント

ディレクトリ構造とファイル名の命名規則を統一することで、テストの目的が明確になります。vitest.config.tspackage.json のスクリプト設定により、層ごとに独立してテストを実行できる環境を整えましょう。

モック戦略を層ごとに適切に使い分けることで、テストの実行速度と信頼性のバランスを最適化できます。Unit では完全モック、Integration では部分モック、Contract では仕様検証という明確な方針を持つことが重要です。

今後の展開

テスト三層設計は、アプリケーションの成長に合わせて進化させることができます。新しいモジュールや外部サービスを追加する際も、三層のどこに配置すべきか判断しやすくなるでしょう。

皆さんもぜひ、Vitest でテスト三層設計を実践してみてください。

関連リンク