T-CREATOR

Jest の層別テスト設計:単体/契約/統合をディレクトリで整然と運用

Jest の層別テスト設計:単体/契約/統合をディレクトリで整然と運用

テストコードが増えてくると、どこに何のテストがあるのかわからなくなってしまった経験はありませんか。単体テスト、統合テスト、契約テストが混在し、実行時間が長くなり、テストの目的も曖昧になってしまいます。

本記事では、Jest を使った層別テスト設計の実践方法をご紹介します。ディレクトリ構造を工夫することで、テストの目的が明確になり、メンテナンス性が向上し、CI/CD パイプラインでの実行も最適化できるようになります。実際のディレクトリ構成例と設定ファイルを交えながら、段階的に解説していきますね。

背景

テスト層の種類と役割

現代のソフトウェア開発では、テストを複数の層に分けて実装することが一般的になっています。それぞれの層には明確な目的があり、適切に分離することでテスト全体の品質と効率が向上するのです。

テストピラミッドという概念では、下層に多くの単体テストを配置し、上層に向かうほどテスト数を減らしていく構造が推奨されています。これは実行時間とメンテナンスコストのバランスを考慮した設計思想なんですね。

以下の図は、テストの層構造とそれぞれの特徴を示しています。

mermaidflowchart TB
  subgraph pyramid["テストピラミッド"]
    e2e["E2E テスト<br/>(ブラウザ全体)"]
    integration["統合テスト<br/>(複数モジュール)"]
    contract["契約テスト<br/>(API 境界)"]
    unit["単体テスト<br/>(関数・クラス)"]
  end

  unit --> contract
  contract --> integration
  integration --> e2e

  unit -.->|"最多<br/>高速"| speed1["実行速度: 速い"]
  contract -.->|"中程度"| speed2["実行速度: 普通"]
  integration -.->|"少数"| speed3["実行速度: 遅い"]
  e2e -.->|"最少<br/>低速"| speed4["実行速度: 非常に遅い"]

図で理解できる要点:

  • テストは下から上に向かって数を減らし、実行時間は長くなる
  • 各層が異なる粒度でアプリケーションを検証している
  • 下層ほど高速でフィードバックが早く、上層ほど実環境に近い検証ができる

各テスト層の特徴

それぞれのテスト層には、次のような特徴があります。

#テスト層検証対象実行速度テスト数の目安
1単体テスト個別の関数やクラス非常に速い(ミリ秒単位)全体の 70-80%
2契約テストAPI やモジュール間の境界速い(秒単位)全体の 10-20%
3統合テスト複数のモジュールや外部サービス遅い(秒〜分単位)全体の 5-10%
4E2E テストアプリケーション全体非常に遅い(分単位)全体の 5% 以下

単体テストは、ビジネスロジックや計算処理など、外部依存のない純粋な関数の動作を検証します。モックやスタブを活用して、高速かつ独立して実行できることが特徴です。

契約テストは、API エンドポイントや外部モジュールとのインターフェースが期待通りに動作するかを検証します。リクエストとレスポンスの形式、エラーハンドリングなどを確認するんですね。

統合テストは、データベース接続や外部 API 呼び出しを含む、実際の環境に近い状態でのテストです。複数のコンポーネントが連携して正しく動作するかを検証します。

課題

テストコードが混在することの問題点

多くのプロジェクトで、テストファイルがソースコードと同じディレクトリに混在したり、すべてのテストが単一の __tests__ ディレクトリに置かれたりしています。これではテストの目的が不明確になり、さまざまな問題が発生してしまうんです。

まず、テスト実行時間の問題があります。開発中に単体テストだけを実行したいのに、統合テストまで実行されてしまい、フィードバックが遅くなります。CI/CD パイプラインでも、すべてのテストを毎回実行することになり、デプロイまでの時間が長くなってしまいますね。

次に、テストのメンテナンス性の問題です。どのテストがどの層に属するのかわからず、リファクタリング時にどのテストを修正すべきか判断が難しくなります。また、新しいテストを追加する際に、どこに配置すべきか迷ってしまうでしょう。

以下の図は、テストが混在した状態での問題点を示しています。

mermaidflowchart LR
  dev["開発者"] -->|"yarn test"| allTests["すべてのテスト<br/>を実行"]
  allTests --> unit["単体テスト<br/>100個 (2秒)"]
  allTests --> contract["契約テスト<br/>30個 (10秒)"]
  allTests --> integration["統合テスト<br/>20個 (60秒)"]

  unit --> result["結果待ち<br/>72秒"]
  contract --> result
  integration --> result

  result -.->|"遅い!"| frustrated["フィードバックが<br/>遅すぎる"]

図で理解できる要点:

  • テストが分離されていないと、すべてのテストが実行されてしまう
  • 単体テストだけでよい場面でも、統合テストの実行を待たなければならない
  • 開発サイクルが遅くなり、生産性が低下する

具体的な課題の例

実際のプロジェクトでよく見られる課題を整理してみましょう。

課題 1:テスト実行の選択ができない

すべてのテストが同じディレクトリにあると、特定の層のテストだけを実行することができません。開発中は単体テストだけを実行したいのに、毎回数分待たされることになります。

課題 2:テストの目的が不明確

ファイル名だけではテストの目的がわかりません。user.test.ts というファイル名では、それが単体テストなのか統合テストなのか判断できないですよね。

課題 3:依存関係の管理が難しい

統合テストではデータベースや外部 API が必要ですが、単体テストでは不要です。テストが混在していると、どのテストにどの環境が必要なのかわかりにくくなります。

課題 4:CI/CD での最適化が困難

プルリクエスト時には単体テストと契約テストだけを実行し、マージ後に統合テストを実行するといった最適化ができません。すべてのテストを毎回実行することになり、CI/CD のコストが増大します。

解決策

ディレクトリ構造による層別分離

これらの課題を解決するために、テストをディレクトリ構造で層別に分離する方法をご紹介します。ディレクトリを分けることで、テストの目的が明確になり、実行も柔軟にコントロールできるようになるんです。

基本的な考え方は、テストの層ごとに専用のディレクトリを作成し、それぞれに適した設定ファイルを配置することです。これにより、テストの実行、環境設定、モックの管理がシンプルになります。

以下の図は、推奨されるディレクトリ構造とテスト実行フローを示しています。

mermaidflowchart TB
  root["プロジェクトルート"] --> src["src/<br/>(ソースコード)"]
  root --> tests["tests/<br/>(テストディレクトリ)"]

  tests --> unit["unit/<br/>(単体テスト)"]
  tests --> contract["contract/<br/>(契約テスト)"]
  tests --> integration["integration/<br/>(統合テスト)"]

  unit --> unitConfig["jest.config.unit.js"]
  contract --> contractConfig["jest.config.contract.js"]
  integration --> integrationConfig["jest.config.integration.js"]

  unitConfig -.->|"高速実行"| dev["開発時に頻繁に実行"]
  contractConfig -.->|"API検証"| pr["PR時に実行"]
  integrationConfig -.->|"実環境検証"| ci["CI/CD で実行"]

図で理解できる要点:

  • テストディレクトリを層ごとに分離することで、目的が明確になる
  • 各層に専用の設定ファイルを配置し、独立して実行できる
  • 開発フェーズに応じて、必要なテストだけを実行できる

推奨ディレクトリ構造

具体的なディレクトリ構造は以下のようになります。

arduinoproject-root/
├── src/
│   ├── services/
│   │   └── userService.ts
│   ├── controllers/
│   │   └── userController.ts
│   └── utils/
│       └── validation.ts
├── tests/
│   ├── unit/
│   │   ├── services/
│   │   │   └── userService.test.ts
│   │   └── utils/
│   │       └── validation.test.ts
│   ├── contract/
│   │   └── api/
│   │       └── userApi.test.ts
│   ├── integration/
│   │   └── scenarios/
│   │       └── userRegistration.test.ts
│   └── setup/
│       ├── unit.setup.ts
│       ├── contract.setup.ts
│       └── integration.setup.ts
├── jest.config.js
├── jest.config.unit.js
├── jest.config.contract.js
├── jest.config.integration.js
└── package.json

このディレクトリ構造では、tests ディレクトリ配下に層別のディレクトリを作成し、さらにその中でソースコードの構造を反映しています。これにより、テストファイルの配置場所が直感的にわかるようになります。

設定ファイルの分離戦略

各テスト層に適した Jest 設定を用意することで、実行環境やタイムアウト、モックの扱いを最適化できます。まず、ベースとなる共通設定ファイルを作成しましょう。

以下は、すべてのテスト層で共通する基本設定です。

typescript// jest.config.js - ベース設定ファイル
module.exports = {
  // TypeScript のトランスパイル設定
  preset: 'ts-jest',

  // テスト実行環境(Node.js 環境)
  testEnvironment: 'node',

  // ソースコードのルートディレクトリ
  roots: ['<rootDir>/src', '<rootDir>/tests'],

  // モジュール解決のパスマッピング
  moduleNameMapper: {
    '^@/(.*)$': '<rootDir>/src/$1',
  },
};

このベース設定では、TypeScript のサポート、テスト実行環境、モジュール解決の基本的な設定を定義しています。パスエイリアス(@​/​)を使うことで、相対パスの複雑さを軽減できますね。

次に、各層専用の設定ファイルを作成します。それぞれの設定ファイルは、ベース設定を継承しつつ、層固有の設定を追加していきます。

単体テスト用の設定

単体テストは高速実行が重要なので、タイムアウトを短く設定し、外部依存をすべてモック化します。

typescript// jest.config.unit.js - 単体テスト設定
const baseConfig = require('./jest.config');

module.exports = {
  ...baseConfig,

  // 単体テストファイルのみをマッチング
  testMatch: ['<rootDir>/tests/unit/**/*.test.ts'],

  // 高速実行のため短いタイムアウト
  testTimeout: 5000,

  // セットアップファイル
  setupFilesAfterEnv: [
    '<rootDir>/tests/setup/unit.setup.ts',
  ],

  // カバレッジ収集の対象
  collectCoverageFrom: [
    'src/**/*.ts',
    '!src/**/*.d.ts',
    '!src/**/index.ts',
  ],

  // カバレッジ閾値(単体テストは高めに設定)
  coverageThreshold: {
    global: {
      branches: 80,
      functions: 80,
      lines: 80,
      statements: 80,
    },
  },
};

単体テスト設定では、タイムアウトを 5 秒に制限し、カバレッジの閾値を 80% に設定しています。これにより、ビジネスロジックの品質を担保できますね。

契約テスト用の設定

契約テストでは、API のリクエスト・レスポンスを検証するため、HTTP クライアントの設定やタイムアウトを調整します。

typescript// jest.config.contract.js - 契約テスト設定
const baseConfig = require('./jest.config');

module.exports = {
  ...baseConfig,

  // 契約テストファイルのみをマッチング
  testMatch: ['<rootDir>/tests/contract/**/*.test.ts'],

  // API 呼び出しを考慮したタイムアウト
  testTimeout: 15000,

  // セットアップファイル
  setupFilesAfterEnv: [
    '<rootDir>/tests/setup/contract.setup.ts',
  ],

  // カバレッジ収集は契約テストでは不要
  collectCoverage: false,

  // グローバル変数の設定(API ベース URL など)
  globals: {
    API_BASE_URL:
      process.env.API_BASE_URL || 'http://localhost:3000',
  },
};

契約テスト設定では、API 呼び出しの時間を考慮してタイムアウトを 15 秒に設定しています。また、環境変数から API のベース URL を取得できるようにしています。

統合テスト用の設定

統合テストでは、データベースや外部サービスとの連携が含まれるため、さらに長いタイムアウトと環境セットアップが必要です。

typescript// jest.config.integration.js - 統合テスト設定
const baseConfig = require('./jest.config');

module.exports = {
  ...baseConfig,

  // 統合テストファイルのみをマッチング
  testMatch: ['<rootDir>/tests/integration/**/*.test.ts'],

  // データベース操作を考慮した長いタイムアウト
  testTimeout: 30000,

  // セットアップファイル
  setupFilesAfterEnv: [
    '<rootDir>/tests/setup/integration.setup.ts',
  ],

  // カバレッジ収集は統合テストでは不要
  collectCoverage: false,

  // テストの順次実行(データベースの競合を避ける)
  maxWorkers: 1,

  // グローバル変数の設定
  globals: {
    DATABASE_URL: process.env.TEST_DATABASE_URL,
    API_BASE_URL:
      process.env.API_BASE_URL || 'http://localhost:3000',
  },
};

統合テスト設定では、タイムアウトを 30 秒に設定し、maxWorkers: 1 で並列実行を無効化しています。これにより、データベースのトランザクション競合を防げるんですね。

package.json でのスクリプト定義

各層のテストを簡単に実行できるよう、npm スクリプトを定義します。

json{
  "scripts": {
    "test": "yarn test:unit && yarn test:contract && yarn test:integration",
    "test:unit": "jest --config jest.config.unit.js",
    "test:contract": "jest --config jest.config.contract.js",
    "test:integration": "jest --config jest.config.integration.js",
    "test:unit:watch": "jest --config jest.config.unit.js --watch",
    "test:unit:coverage": "jest --config jest.config.unit.js --coverage",
    "test:ci": "yarn test:unit && yarn test:contract",
    "test:ci:full": "yarn test"
  }
}

これらのスクリプトにより、開発時は yarn test:unit:watch で単体テストを監視し、CI では yarn test:ci で単体テストと契約テストのみを実行できます。デプロイ前には yarn test:ci:full ですべてのテストを実行するといった使い分けができるんですね。

具体例

単体テストの実装例

それでは、実際のテストコードを見ていきましょう。まず、テスト対象となるサービスクラスを定義します。

以下は、ユーザーのバリデーション処理を行うシンプルなサービスです。

typescript// src/services/userService.ts
export interface User {
  id: string;
  email: string;
  age: number;
}

export class UserService {
  /**
   * メールアドレスの形式を検証する
   * @param email 検証するメールアドレス
   * @returns 有効な場合は true
   */
  validateEmail(email: string): boolean {
    const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
    return emailRegex.test(email);
  }

  /**
   * ユーザーの年齢が有効範囲内かを検証する
   * @param age 検証する年齢
   * @returns 有効な場合は true
   */
  validateAge(age: number): boolean {
    return age >= 0 && age <= 150;
  }
}

このサービスクラスには、外部依存がなく、純粋な関数のみが含まれています。単体テストに最適な構造ですね。

次に、このサービスの単体テストを作成します。テストファイルは tests​/​unit​/​services​/​ ディレクトリに配置します。

typescript// tests/unit/services/userService.test.ts
import { UserService } from '@/services/userService';

describe('UserService', () => {
  let service: UserService;

  // 各テストの前にサービスインスタンスを作成
  beforeEach(() => {
    service = new UserService();
  });

  describe('validateEmail', () => {
    // 正常系のテスト
    it('有効なメールアドレスの場合、trueを返す', () => {
      expect(
        service.validateEmail('user@example.com')
      ).toBe(true);
      expect(
        service.validateEmail('test.user@domain.co.jp')
      ).toBe(true);
    });
  });
});

単体テストでは、各メソッドの振る舞いを細かく検証していきます。次に、異常系のテストも追加しましょう。

typescript// tests/unit/services/userService.test.ts(続き)
describe('UserService', () => {
  // ...前述のコード

  describe('validateEmail', () => {
    // ...前述のテスト

    // 異常系のテスト
    it('無効なメールアドレスの場合、falseを返す', () => {
      expect(service.validateEmail('invalid')).toBe(false);
      expect(service.validateEmail('user@')).toBe(false);
      expect(service.validateEmail('@example.com')).toBe(
        false
      );
      expect(service.validateEmail('')).toBe(false);
    });
  });

  describe('validateAge', () => {
    it('有効な年齢の場合、trueを返す', () => {
      expect(service.validateAge(0)).toBe(true);
      expect(service.validateAge(25)).toBe(true);
      expect(service.validateAge(150)).toBe(true);
    });
  });
});

年齢バリデーションについても、境界値を含む異常系のテストを追加します。

typescript// tests/unit/services/userService.test.ts(続き)
describe('UserService', () => {
  // ...前述のコード

  describe('validateAge', () => {
    // ...前述のテスト

    it('無効な年齢の場合、falseを返す', () => {
      expect(service.validateAge(-1)).toBe(false);
      expect(service.validateAge(151)).toBe(false);
      expect(service.validateAge(NaN)).toBe(false);
    });
  });
});

このように、単体テストでは個別のメソッドの振る舞いを徹底的に検証します。外部依存がないため、テストは非常に高速に実行されるんですね。

契約テストの実装例

次に、契約テストの例を見ていきましょう。契約テストでは、API エンドポイントが期待通りのリクエストを受け付け、正しいレスポンスを返すかを検証します。

まず、テスト対象となる API エンドポイントの定義を確認します。

typescript// src/controllers/userController.ts
import { Request, Response } from 'express';
import { UserService } from '@/services/userService';

export class UserController {
  private userService: UserService;

  constructor() {
    this.userService = new UserService();
  }

  /**
   * ユーザー登録エンドポイント
   * POST /api/users
   */
  async createUser(req: Request, res: Response) {
    const { email, age } = req.body;

    // バリデーション実行
    if (!this.userService.validateEmail(email)) {
      return res
        .status(400)
        .json({ error: 'Invalid email' });
    }

    if (!this.userService.validateAge(age)) {
      return res.status(400).json({ error: 'Invalid age' });
    }

    // ユーザー作成処理(省略)
    return res.status(201).json({ id: '123', email, age });
  }
}

このコントローラーは、リクエストボディを検証し、適切なステータスコードとレスポンスを返します。契約テストでは、この入出力の契約を検証するんですね。

契約テスト用のセットアップファイルを作成します。このファイルでは、テストサーバーの起動や共通の設定を行います。

typescript// tests/setup/contract.setup.ts
import express, { Express } from 'express';
import { UserController } from '@/controllers/userController';

// テストサーバーをグローバル変数として定義
declare global {
  var testApp: Express;
  var testServer: any;
}

// テスト開始前にサーバーを起動
beforeAll(async () => {
  const app = express();
  app.use(express.json());

  const userController = new UserController();
  app.post('/api/users', (req, res) =>
    userController.createUser(req, res)
  );

  global.testApp = app;
  global.testServer = app.listen(3001);
});

// テスト終了後にサーバーを停止
afterAll(async () => {
  await global.testServer.close();
});

このセットアップファイルにより、すべての契約テストで同じテストサーバーを使用できます。次に、実際の契約テストを作成しましょう。

typescript// tests/contract/api/userApi.test.ts
import request from 'supertest';

describe('POST /api/users - ユーザー作成API', () => {
  describe('正常系', () => {
    it('有効なデータの場合、201ステータスとユーザーデータを返す', async () => {
      const response = await request(global.testApp)
        .post('/api/users')
        .send({
          email: 'test@example.com',
          age: 25,
        });

      expect(response.status).toBe(201);
      expect(response.body).toHaveProperty('id');
      expect(response.body.email).toBe('test@example.com');
      expect(response.body.age).toBe(25);
    });
  });
});

契約テストでは、HTTP リクエストを実際に送信し、ステータスコードとレスポンスボディの形式を検証します。続いて、エラーケースも確認しましょう。

typescript// tests/contract/api/userApi.test.ts(続き)
describe('POST /api/users - ユーザー作成API', () => {
  // ...前述のテスト

  describe('異常系', () => {
    it('無効なメールアドレスの場合、400エラーを返す', async () => {
      const response = await request(global.testApp)
        .post('/api/users')
        .send({
          email: 'invalid-email',
          age: 25,
        });

      expect(response.status).toBe(400);
      expect(response.body).toHaveProperty('error');
      expect(response.body.error).toBe('Invalid email');
    });

    it('無効な年齢の場合、400エラーを返す', async () => {
      const response = await request(global.testApp)
        .post('/api/users')
        .send({
          email: 'test@example.com',
          age: -1,
        });

      expect(response.status).toBe(400);
      expect(response.body.error).toBe('Invalid age');
    });
  });
});

契約テストにより、API の入出力が仕様通りに動作することを保証できます。バックエンドとフロントエンドの間で契約が守られていることを確認できるんですね。

統合テストの実装例

最後に、統合テストの例を見ていきましょう。統合テストでは、データベースや外部サービスを含む実際のシナリオを検証します。

まず、統合テスト用のセットアップファイルを作成します。このファイルでは、テストデータベースの初期化や外部サービスのモックを設定します。

typescript// tests/setup/integration.setup.ts
import { Pool } from 'pg';

// テストデータベース接続プールをグローバル変数として定義
declare global {
  var dbPool: Pool;
}

// テスト開始前にデータベース接続を確立
beforeAll(async () => {
  global.dbPool = new Pool({
    connectionString: process.env.TEST_DATABASE_URL,
  });

  // テーブルを作成
  await global.dbPool.query(`
    CREATE TABLE IF NOT EXISTS users (
      id SERIAL PRIMARY KEY,
      email VARCHAR(255) UNIQUE NOT NULL,
      age INTEGER NOT NULL,
      created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
    );
  `);
});

データベース接続を確立し、必要なテーブルを作成しています。次に、各テスト後のクリーンアップ処理を追加します。

typescript// tests/setup/integration.setup.ts(続き)

// 各テスト後にデータをクリーンアップ
afterEach(async () => {
  await global.dbPool.query(
    'TRUNCATE TABLE users RESTART IDENTITY CASCADE;'
  );
});

// テスト終了後にデータベース接続を切断
afterAll(async () => {
  await global.dbPool.query('DROP TABLE IF EXISTS users;');
  await global.dbPool.end();
});

このセットアップにより、各テストが独立した状態で実行できます。それでは、実際の統合テストを作成しましょう。

typescript// tests/integration/scenarios/userRegistration.test.ts
import request from 'supertest';

describe('ユーザー登録シナリオ', () => {
  it('新規ユーザーが登録でき、データベースに保存される', async () => {
    // 1. ユーザー登録APIを呼び出す
    const createResponse = await request(global.testApp)
      .post('/api/users')
      .send({
        email: 'newuser@example.com',
        age: 30,
      });

    expect(createResponse.status).toBe(201);
    const userId = createResponse.body.id;

    // 2. データベースに保存されたことを確認
    const dbResult = await global.dbPool.query(
      'SELECT * FROM users WHERE id = $1',
      [userId]
    );

    expect(dbResult.rows).toHaveLength(1);
    expect(dbResult.rows[0].email).toBe(
      'newuser@example.com'
    );
    expect(dbResult.rows[0].age).toBe(30);
  });
});

統合テストでは、API 呼び出しとデータベースへの保存を含む、エンドツーエンドのシナリオを検証します。次に、エラーケースも確認しましょう。

typescript// tests/integration/scenarios/userRegistration.test.ts(続き)
describe('ユーザー登録シナリオ', () => {
  // ...前述のテスト

  it('重複するメールアドレスの場合、登録に失敗する', async () => {
    // 1. 最初のユーザーを登録
    await request(global.testApp).post('/api/users').send({
      email: 'duplicate@example.com',
      age: 25,
    });

    // 2. 同じメールアドレスで再度登録を試みる
    const duplicateResponse = await request(global.testApp)
      .post('/api/users')
      .send({
        email: 'duplicate@example.com',
        age: 30,
      });

    // 3. エラーレスポンスを確認
    expect(duplicateResponse.status).toBe(409);
    expect(duplicateResponse.body.error).toContain(
      'already exists'
    );

    // 4. データベースに1件のみ保存されていることを確認
    const dbResult = await global.dbPool.query(
      'SELECT COUNT(*) FROM users WHERE email = $1',
      ['duplicate@example.com']
    );

    expect(parseInt(dbResult.rows[0].count)).toBe(1);
  });
});

統合テストにより、複数のコンポーネントが連携して正しく動作することを確認できます。実際のビジネスシナリオに即したテストを実装できるんですね。

テスト実行フローの全体像

これまで見てきたテストの実行フローを図で整理してみましょう。

mermaidflowchart TB
  start["開発者がコードを変更"] --> commit["git commit"]

  commit --> pr["プルリクエスト作成"]

  pr --> unitTest["単体テスト実行<br/>(yarn test:unit)"]
  unitTest -->|"成功"| contractTest["契約テスト実行<br/>(yarn test:contract)"]
  unitTest -->|"失敗"| failed["テスト失敗<br/>PR をブロック"]

  contractTest -->|"成功"| review["コードレビュー"]
  contractTest -->|"失敗"| failed

  review -->|"承認"| merge["main ブランチへマージ"]

  merge --> integrationTest["統合テスト実行<br/>(yarn test:integration)"]
  integrationTest -->|"成功"| deploy["本番環境へデプロイ"]
  integrationTest -->|"失敗"| rollback["デプロイ中止"]

図で理解できる要点:

  • プルリクエスト時は高速なテスト(単体・契約)のみを実行
  • マージ後に時間のかかる統合テストを実行
  • 段階的なテスト実行により、フィードバックを早期に得られる

このフローにより、開発者は素早くフィードバックを得られ、CI/CD パイプラインも効率的に動作します。テストの失敗が早期に検出されるため、問題の特定も容易になるんですね。

GitHub Actions での実装例

実際の CI/CD パイプラインでの設定例を見てみましょう。GitHub Actions を使った設定ファイルです。

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

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

jobs:
  # プルリクエスト時は単体テストと契約テストのみ実行
  unit-and-contract-tests:
    runs-on: ubuntu-latest
    if: github.event_name == 'pull_request'

    steps:
      - uses: actions/checkout@v3

      - name: Setup Node.js
        uses: actions/setup-node@v3
        with:
          node-version: '18'
          cache: 'yarn'

まず、Node.js 環境をセットアップします。次に、依存関係をインストールし、テストを実行します。

yaml# .github/workflows/test.yml(続き)
jobs:
  unit-and-contract-tests:
    # ...前述の設定

    steps:
      # ...前述のステップ

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

      - name: Run unit tests
        run: yarn test:unit:coverage

      - name: Run contract tests
        run: yarn test:contract

      - name: Upload coverage
        uses: codecov/codecov-action@v3
        with:
          files: ./coverage/coverage-final.json

プルリクエスト時は、カバレッジ付きの単体テストと契約テストを実行します。次に、マージ後の統合テスト設定を見てみましょう。

yaml# .github/workflows/test.yml(続き)
jobs:
  # ...前述のジョブ

  # マージ後は統合テストも実行
  integration-tests:
    runs-on: ubuntu-latest
    if: github.event_name == 'push' && github.ref == 'refs/heads/main'

    services:
      postgres:
        image: postgres:15
        env:
          POSTGRES_PASSWORD: testpass
          POSTGRES_DB: testdb
        options: >-
          --health-cmd pg_isready
          --health-interval 10s
          --health-timeout 5s
          --health-retries 5

統合テスト用のジョブでは、PostgreSQL コンテナを起動し、実際のデータベース環境を用意します。

yaml# .github/workflows/test.yml(続き)
jobs:
  integration-tests:
    # ...前述の設定

    steps:
      - uses: actions/checkout@v3

      - name: Setup Node.js
        uses: actions/setup-node@v3
        with:
          node-version: '18'
          cache: 'yarn'

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

      - name: Run integration tests
        env:
          TEST_DATABASE_URL: postgres://postgres:testpass@localhost:5432/testdb
        run: yarn test:integration

この設定により、プルリクエスト時は数秒で結果が得られ、マージ後に詳細な統合テストが実行されます。効率的なパイプラインが構築できるんですね。

まとめ

Jest を使った層別テスト設計について、ディレクトリ構造と設定ファイルの分離を中心に解説してきました。テストを単体・契約・統合の 3 層に分けることで、それぞれの目的が明確になり、実行速度とメンテナンス性が大きく向上します。

ディレクトリを層別に分離することで、開発者はテストの配置場所に迷わず、新しいテストを追加する際も一貫性を保てます。また、Jest の設定ファイルを層ごとに用意することで、タイムアウトやモックの扱いを最適化でき、各層の特性に合った実行環境を構築できるんですね。

CI/CD パイプラインでは、プルリクエスト時には高速な単体テストと契約テストのみを実行し、マージ後に時間のかかる統合テストを実行することで、開発サイクルを加速できます。テストの失敗も早期に検出でき、問題の特定が容易になります。

この設計手法は、小規模なプロジェクトから大規模なアプリケーションまで適用できる普遍的なアプローチです。テストコードの品質を向上させ、保守性の高いコードベースを実現するために、ぜひ導入を検討してみてください。テストが整然と管理されることで、開発チーム全体の生産性が向上していくでしょう。

関連リンク