10日で覚えるJestDay 10: CI/CDとベストプラクティス

Day 10: CI/CDとベストプラクティス

今日学ぶこと

  • CI環境でJestを実行する(GitHub Actionsの例)
  • Husky + lint-stagedによるプリコミットフック
  • テスト戦略:何をテストし、何をテストしないか
  • メンテナブルなテストの書き方
  • テストの組織化パターン
  • フレーキーテストへの対処
  • パフォーマンスの最適化
  • レガシーコードのテスト
  • Jestエコシステム:便利なプラグインとツール

CI環境でJestを実行する

CI(継続的インテグレーション)環境でテストを自動実行することで、コードの品質を継続的に保証できます。

flowchart LR
    subgraph Dev["開発"]
        PUSH["git push"]
    end
    subgraph CI["CI/CD パイプライン"]
        BUILD["ビルド"]
        TEST["テスト実行"]
        LINT["リント"]
        DEPLOY["デプロイ"]
    end
    PUSH --> BUILD --> TEST --> LINT --> DEPLOY
    style Dev fill:#3b82f6,color:#fff
    style CI fill:#22c55e,color:#fff

GitHub Actions の設定例

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

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

jobs:
  test:
    runs-on: ubuntu-latest

    strategy:
      matrix:
        node-version: [18, 20, 22]

    steps:
      - uses: actions/checkout@v4

      - name: Use Node.js ${{ matrix.node-version }}
        uses: actions/setup-node@v4
        with:
          node-version: ${{ matrix.node-version }}
          cache: 'npm'

      - name: Install dependencies
        run: npm ci

      - name: Run tests
        run: npm test -- --ci --coverage --maxWorkers=2

      - name: Upload coverage
        if: matrix.node-version == 20
        uses: codecov/codecov-action@v4
        with:
          files: ./coverage/lcov.info

CI環境向けの Jest 設定

// jest.config.js
module.exports = {
  ci: process.env.CI === 'true',
  collectCoverage: process.env.CI === 'true',
  coverageThreshold: {
    global: {
      branches: 80,
      functions: 80,
      lines: 80,
      statements: 80,
    },
  },
};

TypeScript版:

// jest.config.ts
import type { Config } from 'jest';

const config: Config = {
  collectCoverage: process.env.CI === 'true',
  coverageThreshold: {
    global: {
      branches: 80,
      functions: 80,
      lines: 80,
      statements: 80,
    },
  },
};

export default config;

ポイント: --ci フラグを使うと、スナップショットの自動更新が無効になり、新しいスナップショットがあるとテストが失敗します。これにより、意図しないスナップショットの変更を防げます。


プリコミットフック(Husky + lint-staged)

コミット前にテストとリントを自動実行することで、品質の低いコードがリポジトリに入るのを防ぎます。

セットアップ

# Husky と lint-staged をインストール
npm install --save-dev husky lint-staged

# Husky を初期化
npx husky init

設定

// package.json
{
  "lint-staged": {
    "*.{js,ts,jsx,tsx}": [
      "eslint --fix",
      "jest --bail --findRelatedTests"
    ]
  }
}
# .husky/pre-commit
npx lint-staged
flowchart TB
    subgraph Hook["プリコミットフック"]
        COMMIT["git commit"]
        STAGED["変更ファイル検出"]
        LINT["ESLint実行"]
        TEST["関連テスト実行"]
        PASS["コミット成功"]
        FAIL["コミット中止"]
    end
    COMMIT --> STAGED --> LINT --> TEST
    TEST -->|成功| PASS
    TEST -->|失敗| FAIL
    style Hook fill:#8b5cf6,color:#fff

重要: --findRelatedTests は変更されたファイルに関連するテストだけを実行するため、コミット時の待ち時間を最小限にできます。


テスト戦略:何をテストし、何をテストしないか

テストすべきもの

カテゴリ
ビジネスロジック 計算処理、データ変換、バリデーション
エッジケース 空配列、null、境界値
エラーハンドリング 例外処理、フォールバック動作
パブリックAPI 関数の入出力、コンポーネントのレンダリング結果
統合ポイント API呼び出し、データベース操作

テストしなくてよいもの

カテゴリ 理由
実装の詳細 リファクタリングで壊れるため
サードパーティライブラリ ライブラリ側がテスト済み
定数値 変化しないため
純粋なCSSスタイル ビジュアルテストで対応
privateメソッド パブリックAPIを通してテストする
flowchart TB
    subgraph Do["テストすべき ✓"]
        BL["ビジネスロジック"]
        EDGE["エッジケース"]
        ERR["エラーハンドリング"]
        API["パブリックAPI"]
    end
    subgraph Dont["テスト不要 ✗"]
        IMPL["実装の詳細"]
        LIB["サードパーティ"]
        CONST["定数値"]
        PRIV["privateメソッド"]
    end
    style Do fill:#22c55e,color:#fff
    style Dont fill:#ef4444,color:#fff

テストピラミッド

flowchart TB
    E2E["E2Eテスト\n(少数・低速・高コスト)"]
    INT["統合テスト\n(中程度)"]
    UNIT["ユニットテスト\n(多数・高速・低コスト)"]
    E2E --- INT --- UNIT
    style E2E fill:#ef4444,color:#fff
    style INT fill:#f59e0b,color:#fff
    style UNIT fill:#22c55e,color:#fff

メンテナブルなテストを書く

アンチパターン:実装の詳細をテストする

// Bad: internal state tested
test('adds item to cart', () => {
  const cart = new ShoppingCart();
  cart.addItem({ id: 1, name: 'Book', price: 1500 });

  // Implementation detail: testing internal array
  expect(cart._items).toHaveLength(1);
  expect(cart._items[0].id).toBe(1);
});

// Good: test public behavior
test('adds item to cart', () => {
  const cart = new ShoppingCart();
  cart.addItem({ id: 1, name: 'Book', price: 1500 });

  expect(cart.getItemCount()).toBe(1);
  expect(cart.getTotalPrice()).toBe(1500);
  expect(cart.hasItem(1)).toBe(true);
});

TypeScript版:

interface CartItem {
  id: number;
  name: string;
  price: number;
}

// Good: test public behavior
test('adds item to cart', () => {
  const cart = new ShoppingCart();
  const item: CartItem = { id: 1, name: 'Book', price: 1500 };
  cart.addItem(item);

  expect(cart.getItemCount()).toBe(1);
  expect(cart.getTotalPrice()).toBe(1500);
  expect(cart.hasItem(1)).toBe(true);
});

ベストプラクティス:AAAパターン

test('applies discount to total price', () => {
  // Arrange: setup
  const cart = new ShoppingCart();
  cart.addItem({ id: 1, name: 'Book', price: 2000 });
  cart.addItem({ id: 2, name: 'Pen', price: 500 });

  // Act: perform the action
  cart.applyDiscount(0.1); // 10% off

  // Assert: verify the result
  expect(cart.getTotalPrice()).toBe(2250);
});

テストの命名規則

// Good: describes behavior
describe('ShoppingCart', () => {
  describe('applyDiscount', () => {
    test('reduces total price by the given percentage', () => {});
    test('throws error when discount is negative', () => {});
    test('does not apply discount when cart is empty', () => {});
  });
});

// Bad: vague names
describe('ShoppingCart', () => {
  test('discount works', () => {});
  test('test error', () => {});
});

テストの組織化パターン

パターン1: 機能別(推奨)

src/
├── features/
│   ├── auth/
│   │   ├── login.ts
│   │   ├── login.test.ts
│   │   ├── register.ts
│   │   └── register.test.ts
│   └── cart/
│       ├── cart.ts
│       ├── cart.test.ts
│       ├── checkout.ts
│       └── checkout.test.ts

パターン2: タイプ別

src/
├── components/
│   ├── Button.tsx
│   └── Header.tsx
├── __tests__/
│   ├── unit/
│   │   ├── Button.test.tsx
│   │   └── Header.test.tsx
│   ├── integration/
│   │   └── checkout.test.ts
│   └── e2e/
│       └── purchase-flow.test.ts
パターン メリット デメリット
機能別(コロケーション) テストとコードが近い、変更時に見つけやすい ディレクトリが大きくなる
タイプ別 テストの種類が明確 コードとテストが離れている

推奨: 小〜中規模プロジェクトでは機能別(コロケーション)が最もメンテナンスしやすいです。テストファイルを対象コードの隣に置きましょう。


フレーキーテストへの対処

フレーキーテスト(不安定なテスト)は、同じコードに対して成功と失敗がランダムに切り替わるテストです。

主な原因と対策

原因 対策
テスト間の状態共有 beforeEach/afterEach で状態をリセット
タイミング依存 waitFor、fake timers を使用
外部サービス依存 モックで置き換え
ランダムデータ 固定シード値を使用
テストの実行順序依存 --randomize で検出
// Bad: timing-dependent
test('shows notification after delay', () => {
  showNotification('Hello');
  expect(screen.getByText('Hello')).toBeInTheDocument(); // may fail
});

// Good: use fake timers
test('shows notification after delay', () => {
  jest.useFakeTimers();
  showNotification('Hello');
  jest.advanceTimersByTime(1000);
  expect(screen.getByText('Hello')).toBeInTheDocument();
  jest.useRealTimers();
});
// Bad: shared state
let counter = 0;

test('increments counter', () => {
  counter++;
  expect(counter).toBe(1);
});

test('counter is still 1', () => {
  expect(counter).toBe(1); // depends on execution order
});

// Good: isolated state
describe('counter tests', () => {
  let counter: number;

  beforeEach(() => {
    counter = 0;
  });

  test('increments counter', () => {
    counter++;
    expect(counter).toBe(1);
  });

  test('starts fresh', () => {
    expect(counter).toBe(0);
  });
});

フレーキーテストの検出

# Run tests in random order
npx jest --randomize

# Run a test multiple times to check flakiness
for i in {1..10}; do npx jest --testPathPattern="suspect.test" || break; done

パフォーマンスの最適化

大規模プロジェクトではテストの実行速度が重要になります。

主要なフラグ

フラグ 説明 用途
--maxWorkers=N ワーカー数を制限 CI環境でリソースを制御
--runInBand シリアル実行(ワーカーなし) メモリ制限のある環境
--shard=i/n テストをN分割してi番目を実行 並列CI
--onlyChanged 変更ファイル関連のテストのみ ローカル開発
--bail 最初の失敗で停止 高速フィードバック

CI でのシャーディング

# .github/workflows/test.yml
jobs:
  test:
    strategy:
      matrix:
        shard: [1, 2, 3, 4]
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: 20
          cache: 'npm'
      - run: npm ci
      - run: npx jest --shard=${{ matrix.shard }}/4
flowchart LR
    subgraph Sharding["テストシャーディング"]
        ALL["全テスト\n(400件)"]
        S1["シャード1\n100件"]
        S2["シャード2\n100件"]
        S3["シャード3\n100件"]
        S4["シャード4\n100件"]
    end
    ALL --> S1
    ALL --> S2
    ALL --> S3
    ALL --> S4
    style Sharding fill:#3b82f6,color:#fff

その他の最適化テクニック

// jest.config.js
module.exports = {
  // Transform cache to speed up subsequent runs
  cacheDirectory: '/tmp/jest-cache',

  // Only collect coverage for source files
  collectCoverageFrom: [
    'src/**/*.{js,ts,jsx,tsx}',
    '!src/**/*.d.ts',
    '!src/**/index.ts',
  ],

  // Skip expensive transforms for node_modules
  transformIgnorePatterns: ['/node_modules/'],
};

レガシーコードのテスト

テストのないレガシーコードにテストを追加するための段階的アプローチです。

ステップ1: 特性テスト(Characterization Tests)

まず現在の動作を記録します。

// Legacy function with no tests
function calculateShipping(weight, destination) {
  if (destination === 'domestic') {
    if (weight < 1) return 500;
    if (weight < 5) return 800;
    return 1200;
  }
  if (weight < 1) return 2000;
  if (weight < 5) return 3500;
  return 5000;
}

// Characterization test: document current behavior
describe('calculateShipping (characterization)', () => {
  test('domestic shipping rates', () => {
    expect(calculateShipping(0.5, 'domestic')).toBe(500);
    expect(calculateShipping(3, 'domestic')).toBe(800);
    expect(calculateShipping(10, 'domestic')).toBe(1200);
  });

  test('international shipping rates', () => {
    expect(calculateShipping(0.5, 'international')).toBe(2000);
    expect(calculateShipping(3, 'international')).toBe(3500);
    expect(calculateShipping(10, 'international')).toBe(5000);
  });
});

ステップ2: 安全にリファクタリング

テストがあるので、安心してリファクタリングできます。

// Refactored version
interface ShippingRate {
  maxWeight: number;
  price: number;
}

const DOMESTIC_RATES: ShippingRate[] = [
  { maxWeight: 1, price: 500 },
  { maxWeight: 5, price: 800 },
  { maxWeight: Infinity, price: 1200 },
];

const INTERNATIONAL_RATES: ShippingRate[] = [
  { maxWeight: 1, price: 2000 },
  { maxWeight: 5, price: 3500 },
  { maxWeight: Infinity, price: 5000 },
];

function calculateShipping(weight: number, destination: string): number {
  const rates = destination === 'domestic' ? DOMESTIC_RATES : INTERNATIONAL_RATES;
  const rate = rates.find(r => weight < r.maxWeight);
  return rate?.price ?? rates[rates.length - 1].price;
}

ステップ3: 段階的にカバレッジを向上

flowchart LR
    subgraph Strategy["段階的テスト戦略"]
        S1["1. 特性テスト\n現在の動作を記録"]
        S2["2. リファクタリング\nテストで保護しながら"]
        S3["3. 新機能テスト\n新しいコードは必ず"]
        S4["4. カバレッジ向上\n重要な箇所から"]
    end
    S1 --> S2 --> S3 --> S4
    style Strategy fill:#8b5cf6,color:#fff

ルール: 新しいコードには必ずテストを書く。レガシーコードは修正するタイミングでテストを追加する。


Jestエコシステム:便利なプラグインとツール

jest-extended

標準のマッチャーを拡張する追加マッチャー集です。

npm install --save-dev jest-extended
// jest.config.js
module.exports = {
  setupFilesAfterSetup: ['jest-extended/all'],
};
// Useful additional matchers
test('jest-extended matchers', () => {
  expect([1, 2, 3]).toBeArray();
  expect([1, 2, 3]).toIncludeAllMembers([1, 3]);
  expect('hello').toStartWith('he');
  expect('hello').toEndWith('lo');
  expect(5).toBeWithin(1, 10);
  expect({ a: 1 }).toContainKey('a');
  expect(() => {}).toBeFunction();
  expect(new Date()).toBeDate();
  expect('').toBeEmpty();
  expect(42).toBePositive();
  expect(-1).toBeNegative();
});

@testing-library/jest-dom

DOM要素のテスト用カスタムマッチャーです。

npm install --save-dev @testing-library/jest-dom
import '@testing-library/jest-dom';

test('DOM matchers', () => {
  document.body.innerHTML = `
    <button disabled class="primary">Submit</button>
    <input type="text" value="hello" />
    <div style="display: none">Hidden</div>
  `;

  const button = document.querySelector('button');
  const input = document.querySelector('input');
  const hidden = document.querySelector('div');

  expect(button).toBeDisabled();
  expect(button).toHaveClass('primary');
  expect(button).toHaveTextContent('Submit');
  expect(input).toHaveValue('hello');
  expect(hidden).not.toBeVisible();
});

その他の便利なツール

ツール 説明
jest-extended 追加マッチャー(toBeArray, toStartWithなど)
@testing-library/jest-dom DOM用マッチャー
jest-watch-typeahead ウォッチモードでファイル名/テスト名を絞り込み
jest-html-reporters HTMLテストレポートを生成
jest-image-snapshot ビジュアルリグレッションテスト
jest-when 引数に応じたモック戻り値の設定

まとめ

概念 説明
CI/CD テストを自動実行してコード品質を保証する
プリコミットフック コミット前にリント・テストを実行する
テスト戦略 ビジネスロジックに集中し、実装の詳細は避ける
AAAパターン Arrange、Act、Assert でテストを構造化する
コロケーション テストファイルをソースコードの隣に配置する
フレーキーテスト 状態の分離とタイミング制御で対策する
シャーディング テストを分割して並列実行する
特性テスト レガシーコードの現在の動作を記録する
jest-extended 標準マッチャーを拡張するプラグイン

重要ポイント

  1. CI環境では --ci フラグと --maxWorkers でリソースを制御する
  2. プリコミットフックで --findRelatedTests を使い高速化する
  3. 実装の詳細ではなく、パブリックな振る舞いをテストする
  4. フレーキーテストは根本原因を特定して修正する
  5. レガシーコードは特性テストから始める

練習問題

問題1: 基本

以下のGitHub Actions ワークフローの空欄を埋めてください。

name: Test
on:
  push:
    branches: [main]

jobs:
  test:
    runs-on: ______
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: 20
      - run: npm ci
      - run: npx jest --__ --coverage --maxWorkers=__

問題2: 応用

以下のテストにはフレーキーテストの原因があります。問題を特定し修正してください。

let database = [];

describe('UserRepository', () => {
  test('adds a user', () => {
    database.push({ id: 1, name: 'Alice' });
    expect(database).toHaveLength(1);
  });

  test('finds a user by id', () => {
    const user = database.find(u => u.id === 1);
    expect(user).toBeDefined();
    expect(user.name).toBe('Alice');
  });

  test('database starts empty', () => {
    expect(database).toHaveLength(0); // This fails!
  });
});

チャレンジ問題

以下の要件を満たす jest.config.js を作成してください。

  • CI環境ではカバレッジを収集する
  • カバレッジしきい値は80%以上
  • キャッシュディレクトリは /tmp/jest-cache
  • node_modules.d.ts ファイルはカバレッジから除外
  • CI環境では最大ワーカー数を2に制限

参考リンク


10日間の学習を振り返って

おめでとうございます! 10日間の Jest 学習を完走しました。ここで、学んだ内容を振り返りましょう。

Day テーマ 学んだこと
Day 1 Jestへようこそ Jestのセットアップ、最初のテスト
Day 2 テスト構造と基本パターン describe/test、beforeEach/afterEach
Day 3 マッチャーをマスターする toBe、toEqual、各種マッチャー
Day 4 モック・スタブ・スパイ jest.fn()、jest.mock()、jest.spyOn()
Day 5 非同期コードのテスト async/await、Promise、タイマー
Day 6 テストカバレッジとデバッグ カバレッジレポート、デバッグ手法
Day 7 Reactコンポーネントテスト Testing Library、ユーザーイベント
Day 8 高度なモックパターン モジュールモック、手動モック
Day 9 スナップショットテスト toMatchSnapshot、toMatchInlineSnapshot
Day 10 CI/CDとベストプラクティス CI設定、テスト戦略、パフォーマンス
flowchart TB
    subgraph Journey["10日間のJest学習"]
        D1["Day 1-3\n基礎"]
        D2["Day 4-6\n中級"]
        D3["Day 7-9\n応用"]
        D4["Day 10\n実践"]
    end
    D1 --> D2 --> D3 --> D4
    style D1 fill:#3b82f6,color:#fff
    style D2 fill:#8b5cf6,color:#fff
    style D3 fill:#f59e0b,color:#fff
    style D4 fill:#22c55e,color:#fff

次のステップ

ここからは、実際のプロジェクトでJestを活用していきましょう。

  1. 既存プロジェクトにテストを追加する -- まずは特性テストから始めましょう
  2. CI/CDパイプラインを構築する -- 今日学んだGitHub Actionsの設定を参考に
  3. カバレッジ目標を設定する -- 80%を最初の目標に
  4. Testing Libraryを深く学ぶ -- Reactプロジェクトではほぼ必須
  5. E2Eテスト(Playwright/Cypress) -- Jest単体では不十分なシナリオ向け

テストを書くことは、コードの品質だけでなく、開発者としての自信にもつながります。良いテストを書き続けてください!