T-CREATOR

Jest と Monorepo 構成:複数パッケージでの活用法

Jest と Monorepo 構成:複数パッケージでの活用法

モダンな開発環境では、複数のパッケージを効率的に管理する Monorepo 構成が注目を集めています。特に大規模なプロジェクトにおいて、コードの再利用性や依存関係の管理を向上させる手法として活用されています。そして、この Monorepo 環境でのテスト戦略において、Jest は非常に重要な役割を果たします。

本記事では、Monorepo と Jest を組み合わせた効果的なテスト環境の構築方法について、基礎知識から実践的な実装例まで段階的に解説いたします。Lerna や Yarn Workspaces といった主要なツールでの具体的な設定方法や、チーム開発での運用ノウハウもご紹介しますので、ぜひ最後までお読みください。

Monorepo と Jest の基礎知識

Monorepo 構成の特徴

Monorepo(モノレポ)は、複数のプロジェクトやパッケージを単一のリポジトリで管理する開発手法です。従来の 1 つのリポジトリに 1 つのプロジェクトという考え方とは異なり、関連性の高い複数のプロジェクトをまとめて管理できるのが特徴です。

以下の図で、Monorepo の基本構造を確認しましょう。

mermaidflowchart TD
    root[プロジェクトルート] --> packages[packages/]
    packages --> ui[ui/]
    packages --> api[api/]
    packages --> shared[shared/]

    ui --> ui_pkg[package.json]
    ui --> ui_src[src/]
    ui --> ui_test[__tests__/]

    api --> api_pkg[package.json]
    api --> api_src[src/]
    api --> api_test[__tests__/]

    shared --> shared_pkg[package.json]
    shared --> shared_src[src/]
    shared --> shared_test[__tests__/]

    root --> root_pkg[package.json]
    root --> config[設定ファイル群]

この構造により、コードの共有や依存関係の管理が効率的に行えます。また、各パッケージが独立してバージョン管理されながらも、統一された開発環境を維持できるのです。

Monorepo の主な利点

#利点説明
1コード共有の効率化共通のライブラリやユーティリティを複数のパッケージで利用可能
2依存関係の可視化パッケージ間の依存関係が明確になり、影響範囲の把握が容易
3統一された開発環境リンター、フォーマッター、テストツールなどの設定を統一可能
4効率的な CI/CD変更があったパッケージのみをビルド・デプロイする最適化が可能

Jest の役割と利点

Jest は Facebook(現 Meta)が開発した JavaScript テストフレームワークで、Monorepo 環境において強力な機能を提供します。特に複数パッケージの複雑なテストシナリオを効率的に管理できる点が評価されています。

Jest の主要機能

javascript// Jest の基本的なテスト例
describe('Monorepo での Jest テスト', () => {
  test('基本的なユニットテスト', () => {
    const result = add(2, 3);
    expect(result).toBe(5);
  });

  test('非同期処理のテスト', async () => {
    const data = await fetchUserData(1);
    expect(data.name).toBeDefined();
  });
});

このように、Jest はシンプルな記法で様々なテストパターンに対応できます。

Monorepo における Jest の利点

#利点詳細
1統一されたテスト環境全パッケージで同じテストフレームワークを使用可能
2効率的なテスト実行変更されたファイルのみをテスト対象とする機能
3カバレッジの統合管理複数パッケージのカバレッジを統合して表示
4柔軟な設定管理パッケージごとの個別設定と共通設定の両立

Monorepo での Jest 活用時の課題

Monorepo 環境で Jest を活用する際には、従来の単一プロジェクトでは発生しない特有の課題に直面します。これらの課題を理解することで、適切な解決策を選択できるようになります。

複数パッケージ間の依存関係

Monorepo 環境では、パッケージ間の依存関係が複雑になりがちです。特にテスト実行時において、この依存関係が正しく解決されないと予期しない動作が発生することがあります。

以下の図で、パッケージ間の依存関係とテスト時の問題を示します。

mermaidsequenceDiagram
    participant UI as UI パッケージ
    participant Shared as 共通ライブラリ
    participant API as API パッケージ
    participant Test as テスト実行

    Test->>UI: テスト開始
    UI->>Shared: 共通関数呼び出し
    Shared-->>UI: エラー(未ビルド)
    UI-->>Test: テスト失敗

    Note over Test: 依存関係が正しく<br/>解決されない場合

依存関係での主な問題点

  1. ビルド順序の問題: 依存先パッケージがビルドされていない状態でのテスト実行
  2. パスの解決エラー: TypeScript の型定義や相対パスの解決に失敗
  3. 循環依存の検出: パッケージ間で循環依存が発生している場合の処理

テスト実行の複雑さ

Monorepo 環境では、どのパッケージのテストを実行するかの判断が複雑になります。全パッケージを毎回テストするのは非効率的ですが、必要なテストが実行されない可能性もあります。

テスト実行時の課題

bash# 全パッケージのテスト実行(非効率)
yarn test

# 特定パッケージのみのテスト(依存関係の考慮が必要)
yarn workspace @myorg/ui test

# 変更されたパッケージのみのテスト(複雑な判定ロジック)
yarn test --since origin/main

この例のように、効率的かつ確実なテスト実行には適切な戦略が必要です。

設定ファイルの管理問題

複数のパッケージそれぞれに設定ファイルを配置すると、メンテナンスコストが増大します。一方で、完全に統一された設定では、パッケージ固有の要件に対応できない場合があります。

設定管理の課題

#課題影響
1設定の重複同じ設定を複数箇所で管理する必要
2設定の不整合パッケージごとに異なる設定による動作の違い
3更新の困難さ設定変更時に全パッケージの修正が必要
4デバッグの複雑化どの設定が適用されているかの把握が困難

これらの課題を解決するためには、Jest の設定継承機能や、プロジェクト機能を活用した戦略的なアプローチが重要になります。

Jest の Monorepo 対応解決策

前述の課題を解決するため、Jest には Monorepo 環境に特化した機能が提供されています。これらの機能を適切に活用することで、効率的で保守性の高いテスト環境を構築できます。

Workspace 設定の活用

Jest 28 以降では、プロジェクト機能(projects)を使用して複数のパッケージを効率的に管理できます。この機能により、各パッケージの設定を統合しつつ、個別の要件にも対応可能です。

プロジェクト設定の基本構造

javascript// jest.config.js (ルート)
module.exports = {
  // プロジェクト一覧を定義
  projects: [
    '<rootDir>/packages/*/jest.config.js',
    // または直接設定を記述
    {
      displayName: 'ui',
      testMatch: [
        '<rootDir>/packages/ui/**/*.test.{js,ts}',
      ],
      setupFilesAfterEnv: ['<rootDir>/setup/ui-setup.js'],
    },
  ],

  // 全体の設定
  collectCoverageFrom: [
    '<rootDir>/packages/*/src/**/*.{js,ts}',
    '!**/*.d.ts',
  ],
};

この設定により、各パッケージのテストを統合管理しながら、個別の設定も維持できます。

共通設定ファイルの管理

設定ファイルの重複を避けるため、共通設定を基底クラスとして定義し、各パッケージで継承する方式が効果的です。

共通設定の実装例

javascript// config/jest.base.js
module.exports = {
  preset: 'ts-jest',
  testEnvironment: 'node',

  // 共通のsetupファイル
  setupFilesAfterEnv: [
    '<rootDir>/../../config/jest.setup.js',
  ],

  // 共通のモジュールマッピング
  moduleNameMapping: {
    '^@myorg/(.*)$': '<rootDir>/../$1/src',
  },

  // 共通のカバレッジ設定
  collectCoverageFrom: [
    'src/**/*.{js,ts}',
    '!src/**/*.d.ts',
    '!src/**/*.stories.{js,ts}',
  ],
};
javascript// packages/ui/jest.config.js
const base = require('../../config/jest.base');

module.exports = {
  ...base,
  displayName: 'UI Components',

  // パッケージ固有の設定
  testEnvironment: 'jsdom', // UIコンポーネント用
  setupFilesAfterEnv: [
    ...base.setupFilesAfterEnv,
    '<rootDir>/src/test-utils/setup.js',
  ],
};

このアプローチにより、共通設定の保守性を向上させつつ、パッケージ固有の要件にも対応できます。

パッケージごとのテスト戦略

各パッケージの性質に応じて、最適なテスト戦略を選択することが重要です。以下の図で、パッケージタイプ別のテスト戦略を示します。

mermaidflowchart LR
    packages[パッケージ分類] --> ui[UI Components]
    packages --> lib[ライブラリ]
    packages --> api[API Services]
    packages --> utils[ユーティリティ]

    ui --> ui_test[Jest + Testing Library<br/>Visual Testing<br/>Storybook連携]
    lib --> lib_test[Unit Testing<br/>Integration Testing<br/>型チェック]
    api --> api_test[API Testing<br/>E2E Testing<br/>モック戦略]
    utils --> utils_test[Pure Function Testing<br/>パフォーマンステスト<br/>エッジケーステスト]

パッケージタイプ別テスト設定例

javascript// UI コンポーネント用の設定
const uiConfig = {
  testEnvironment: 'jsdom',
  setupFilesAfterEnv: ['@testing-library/jest-dom'],
  moduleNameMapping: {
    '\\.(css|less|scss|sass)$': 'identity-obj-proxy',
  },
};

// API サービス用の設定
const apiConfig = {
  testEnvironment: 'node',
  setupFilesAfterEnv: ['./src/test-utils/api-setup.js'],
  testTimeout: 10000, // API テスト用の長めのタイムアウト
};

これらの設定により、各パッケージの特性に最適化されたテスト環境を構築できます。

実装例:Lerna + Jest 構成

Lerna は、Monorepo プロジェクトでよく使用される管理ツールです。Jest と Lerna を組み合わせることで、効率的なテスト環境を構築できます。実際のプロジェクト構造から設定ファイルまで、段階的に実装していきましょう。

プロジェクト構造の作成

まずは、Lerna を使用した Monorepo プロジェクトの基本構造を作成します。

bash# Lernaプロジェクトの初期化
npx lerna init

# 必要なパッケージのインストール
yarn add -W -D jest @types/jest ts-jest
yarn add -W -D lerna @testing-library/jest-dom

以下のプロジェクト構造を作成します。

luamy-monorepo/
├── packages/
│   ├── ui/
│   │   ├── src/
│   │   ├── __tests__/
│   │   ├── package.json
│   │   └── jest.config.js
│   ├── api/
│   │   ├── src/
│   │   ├── __tests__/
│   │   ├── package.json
│   │   └── jest.config.js
│   └── shared/
│       ├── src/
│       ├── __tests__/
│       ├── package.json
│       └── jest.config.js
├── config/
│   ├── jest.base.js
│   └── jest.setup.js
├── jest.config.js
├── lerna.json
└── package.json

設定ファイルの実装

Lerna 設定ファイル

json{
  "version": "independent",
  "npmClient": "yarn",
  "useWorkspaces": true,
  "packages": ["packages/*"],
  "command": {
    "test": {
      "stream": true
    },
    "run": {
      "npmClient": "yarn"
    }
  }
}

ルートパッケージの設定

json{
  "name": "my-monorepo",
  "private": true,
  "workspaces": ["packages/*"],
  "scripts": {
    "test": "jest",
    "test:watch": "jest --watch",
    "test:coverage": "jest --coverage",
    "test:changed": "lerna run test --since HEAD~1"
  },
  "devDependencies": {
    "jest": "^29.0.0",
    "@types/jest": "^29.0.0",
    "ts-jest": "^29.0.0",
    "lerna": "^6.0.0"
  }
}

Jest 基本設定ファイル

javascript// config/jest.base.js
module.exports = {
  preset: 'ts-jest',
  testEnvironment: 'node',

  // ルートディレクトリの設定
  rootDir: process.cwd(),

  // テストファイルのパターン
  testMatch: [
    '<rootDir>/src/**/*.test.{js,ts}',
    '<rootDir>/__tests__/**/*.{js,ts}',
  ],

  // セットアップファイル
  setupFilesAfterEnv: [
    '<rootDir>/../../config/jest.setup.js',
  ],

  // モジュール解決の設定
  moduleNameMapping: {
    '^@myorg/(.*)$': '<rootDir>/../$1/src',
    '^~/(.*)$': '<rootDir>/src/$1',
  },

  // カバレッジ設定
  collectCoverageFrom: [
    'src/**/*.{js,ts}',
    '!src/**/*.d.ts',
    '!src/**/*.stories.{js,ts}',
    '!src/**/index.{js,ts}',
  ],

  // テストタイムアウト
  testTimeout: 5000,
};

テストスクリプトの作成

効率的なテスト実行スクリプト

javascript// scripts/test-runner.js
const { exec } = require('child_process');
const { promisify } = require('util');

const execAsync = promisify(exec);

async function runTests() {
  try {
    // 変更されたパッケージを取得
    const { stdout } = await execAsync(
      'lerna changed --json'
    );
    const changedPackages = JSON.parse(stdout);

    if (changedPackages.length === 0) {
      console.log('変更されたパッケージがありません');
      return;
    }

    // 変更されたパッケージのテストを実行
    for (const pkg of changedPackages) {
      console.log(`Testing ${pkg.name}...`);
      await execAsync(`lerna run test --scope=${pkg.name}`);
    }

    console.log('すべてのテストが完了しました');
  } catch (error) {
    console.error(
      'テスト実行中にエラーが発生しました:',
      error
    );
    process.exit(1);
  }
}

runTests();

パッケージ別設定の実装

javascript// packages/ui/jest.config.js
const base = require('../../config/jest.base');

module.exports = {
  ...base,
  displayName: 'UI Components',

  // UI コンポーネント用の設定
  testEnvironment: 'jsdom',
  setupFilesAfterEnv: [
    ...base.setupFilesAfterEnv,
    '@testing-library/jest-dom',
  ],

  // CSS モジュールのモック
  moduleNameMapping: {
    ...base.moduleNameMapping,
    '\\.(css|less|scss|sass)$': 'identity-obj-proxy',
    '\\.(jpg|jpeg|png|gif|svg)$':
      '<rootDir>/src/test-utils/file-mock.js',
  },
};
javascript// packages/api/jest.config.js
const base = require('../../config/jest.base');

module.exports = {
  ...base,
  displayName: 'API Services',

  // API テスト用の設定
  testEnvironment: 'node',
  testTimeout: 10000,

  // APIテスト用のセットアップ
  setupFilesAfterEnv: [
    ...base.setupFilesAfterEnv,
    '<rootDir>/src/test-utils/api-setup.js',
  ],
};

この構成により、Lerna を使用した Monorepo 環境で効率的な Jest テストが実行できるようになります。

実装例:Yarn Workspaces + Jest 構成

Yarn Workspaces は、Yarn が提供する Monorepo 管理機能で、依存関係の解決やパッケージ管理を効率的に行えます。Jest と Yarn Workspaces を組み合わせることで、よりシンプルで高性能なテスト環境を構築できます。

Workspace 設定

ルートパッケージの設定

json{
  "name": "my-yarn-monorepo",
  "private": true,
  "workspaces": {
    "packages": ["packages/*", "apps/*"],
    "nohoist": ["**/react", "**/react-dom"]
  },
  "scripts": {
    "test": "jest --projects packages/*/jest.config.js",
    "test:watch": "jest --watch --projects packages/*/jest.config.js",
    "test:coverage": "jest --coverage --projects packages/*/jest.config.js",
    "test:workspace": "yarn workspace",
    "test:changed": "jest --changedSince=main"
  },
  "devDependencies": {
    "jest": "^29.0.0",
    "@types/jest": "^29.0.0",
    "ts-jest": "^29.0.0"
  }
}

Workspace 内パッケージの設定

json{
  "name": "@myorg/ui",
  "version": "1.0.0",
  "main": "dist/index.js",
  "scripts": {
    "test": "jest",
    "test:watch": "jest --watch",
    "build": "tsc",
    "dev": "tsc --watch"
  },
  "dependencies": {
    "@myorg/shared": "*"
  },
  "devDependencies": {
    "@testing-library/react": "^13.0.0",
    "@testing-library/jest-dom": "^5.0.0"
  }
}

以下の図で、Yarn Workspaces での依存関係とテスト実行フローを示します。

mermaidflowchart TD
    root[ルートワークスペース] --> yarn_test[yarn test]
    yarn_test --> jest_projects[Jest Projects機能]

    jest_projects --> ui_pkg[UI パッケージ]
    jest_projects --> api_pkg[API パッケージ]
    jest_projects --> shared_pkg[共通パッケージ]

    ui_pkg --> ui_deps[依存関係解決]
    api_pkg --> api_deps[依存関係解決]
    shared_pkg --> shared_deps[依存関係解決]

    ui_deps --> shared_lib[共通ライブラリ]
    api_deps --> shared_lib
    shared_deps --> external[外部ライブラリ]

    ui_pkg --> ui_test[UIテスト実行]
    api_pkg --> api_test[APIテスト実行]
    shared_pkg --> shared_test[共通テスト実行]

Jest 設定の最適化

マルチプロジェクト設定

javascript// jest.config.js (ルート)
module.exports = {
  projects: [
    {
      displayName: 'UI Components',
      testMatch: [
        '<rootDir>/packages/ui/**/*.test.{js,ts,tsx}',
      ],
      testEnvironment: 'jsdom',
      setupFilesAfterEnv: [
        '<rootDir>/config/jest.setup.js',
        '@testing-library/jest-dom',
      ],
      moduleNameMapping: {
        '^@myorg/(.*)$': '<rootDir>/packages/$1/src',
        '\\.(css|less|scss)$': 'identity-obj-proxy',
      },
    },
    {
      displayName: 'API Services',
      testMatch: [
        '<rootDir>/packages/api/**/*.test.{js,ts}',
      ],
      testEnvironment: 'node',
      setupFilesAfterEnv: [
        '<rootDir>/config/jest.setup.js',
      ],
      moduleNameMapping: {
        '^@myorg/(.*)$': '<rootDir>/packages/$1/src',
      },
    },
    {
      displayName: 'Shared Utils',
      testMatch: [
        '<rootDir>/packages/shared/**/*.test.{js,ts}',
      ],
      testEnvironment: 'node',
      setupFilesAfterEnv: [
        '<rootDir>/config/jest.setup.js',
      ],
      moduleNameMapping: {
        '^@myorg/(.*)$': '<rootDir>/packages/$1/src',
      },
    },
  ],

  // 全体のカバレッジ設定
  collectCoverage: true,
  coverageDirectory: '<rootDir>/coverage',
  coverageReporters: ['text', 'lcov', 'html'],
  collectCoverageFrom: [
    '<rootDir>/packages/*/src/**/*.{js,ts,tsx}',
    '!**/*.d.ts',
    '!**/*.stories.{js,ts,tsx}',
    '!**/node_modules/**',
  ],
};

パフォーマンス最適化設定

javascript// config/jest.performance.js
module.exports = {
  // 並列実行の最適化
  maxWorkers: '50%',

  // キャッシュの活用
  cacheDirectory: '<rootDir>/node_modules/.cache/jest',

  // 不要なファイルの除外
  testPathIgnorePatterns: [
    '/node_modules/',
    '/dist/',
    '/build/',
    '/coverage/',
  ],

  // モジュール解決の高速化
  modulePathIgnorePatterns: [
    '<rootDir>/packages/.*/dist/',
    '<rootDir>/packages/.*/build/',
  ],

  // ウォッチモードの最適化
  watchPathIgnorePatterns: [
    '/node_modules/',
    '/dist/',
    '/build/',
  ],
};

CI/CD での実行方法

GitHub Actions 設定例

yaml# .github/workflows/test.yml
name: Test Suite

on:
  push:
    branches: [main, develop]
  pull_request:
    branches: [main]

jobs:
  test:
    runs-on: ubuntu-latest

    strategy:
      matrix:
        node-version: [16.x, 18.x]

    steps:
      - uses: actions/checkout@v3
        with:
          # 変更されたファイルの履歴を取得
          fetch-depth: 0

      - name: Setup Node.js
        uses: actions/setup-node@v3
        with:
          node-version: ${{ matrix.node-version }}
          cache: 'yarn'

      - name: Install dependencies
        run: yarn install --frozen-lockfile

      - name: Run tests
        run: |
          if [ "${{ github.event_name }}" = "pull_request" ]; then
            # PRの場合は変更されたファイルのみテスト
            yarn test --changedSince=origin/${{ github.base_ref }}
          else
            # mainブランチの場合は全テスト実行
            yarn test --coverage
          fi

      - name: Upload coverage reports
        uses: codecov/codecov-action@v3
        if: matrix.node-version == '18.x'
        with:
          directory: ./coverage

効率的なテスト実行スクリプト

javascript// scripts/run-tests.js
const { execSync } = require('child_process');
const { existsSync } = require('fs');

function runTests() {
  const isCI = process.env.CI === 'true';
  const isPR =
    process.env.GITHUB_EVENT_NAME === 'pull_request';

  let command = 'yarn test';

  if (isCI) {
    if (isPR) {
      // PRの場合は変更されたファイルのみ
      command +=
        ' --changedSince=origin/main --passWithNoTests';
    } else {
      // メインブランチの場合は全テスト + カバレッジ
      command += ' --coverage --watchAll=false';
    }
  } else {
    // ローカル開発環境
    command += ' --watch';
  }

  console.log(`実行コマンド: ${command}`);

  try {
    execSync(command, { stdio: 'inherit' });
  } catch (error) {
    console.error('テスト実行中にエラーが発生しました');
    process.exit(1);
  }
}

runTests();

並列実行での最適化

bash# 複数のワークスペースを並列でテスト
yarn workspaces foreach --parallel run test

# 特定のワークスペースのみをテスト
yarn workspace @myorg/ui test

# 依存関係を考慮した順次実行
yarn workspaces foreach --topological run test

この設定により、Yarn Workspaces の機能を最大限活用した効率的な Jest テスト環境が構築できます。特に大規模な Monorepo プロジェクトにおいて、その効果を実感できるでしょう。

まとめ

本記事では、Monorepo と Jest を組み合わせた効果的なテスト環境の構築について、基礎知識から実践的な実装方法まで詳しく解説いたしました。

Monorepo と Jest の組み合わせは、複数のパッケージを効率的に管理しながら、統一されたテスト戦略を実現する強力な手法です。特に以下の点で大きなメリットがあります。

重要なポイント

#ポイント効果
1統一されたテスト環境全パッケージで一貫したテスト品質を維持
2効率的な依存関係管理パッケージ間の依存関係を適切に解決
3柔軟な設定管理共通設定と個別設定の適切なバランス
4最適化された CI/CD変更されたパッケージのみの効率的なテスト実行

実装においては、Lerna と Yarn Workspaces それぞれに特徴があります。プロジェクトの規模やチームの体制に応じて適切なツールを選択することが重要です。

設定ファイルの管理では、共通設定を基底として各パッケージで継承する方式が効果的でした。この方式により、保守性を保ちながら各パッケージの特性に応じた最適化が可能になります。

CI/CD 環境での運用では、変更されたファイルのみをテスト対象とする機能や、並列実行による高速化など、実用的な最適化手法をご紹介いたしました。

Monorepo と Jest の組み合わせは、モダンな Web アプリケーション開発において欠かせない技術スタックとなっています。本記事でご紹介した手法を参考に、ぜひ皆さまのプロジェクトでも効率的なテスト環境を構築してみてください。

関連リンク