CIのテストが肥大化して10分超え?現場で効いた5段階の処方箋

に公開

「CIが遅いから、PR出したらSlack見ながら待ってる」——この光景に心当たりがあるなら、チームはすでに生産性を失っている。カリフォルニア大学の研究によれば、割り込み後に集中を回復するまで23分かかる。つまりCI10分の実コストは30〜45分だ。これは「待ち時間の問題」ではなく、「1日の可処分時間が消えていく問題」だ。

なぜ10分が問題なのか——数字の裏にある認知コスト

もう少し掘り下げる。カリフォルニア大学アーバイン校の研究(Gloria Mark, 2008)によれば、割り込み後に集中状態を完全回復するまでに23分15秒かかる。

CIに10分待つ間に別のタスクに手を出すと、CIが通った後に元のコンテキストに戻るのにさらに20分以上かかる。10分のCI待ちの実コストは30〜45分になる計算だ。

Kent Beckも Extreme Programming の中でこう述べている。

「ビルドが10分を超えるようになったら、フィードバックの機会を失う。そのビルドはほとんど使われなくなる」

Cortexの2024年開発者生産性調査では、回答者の**90%**が「生産性改善が最重要課題」と評価し、週5〜15時間を自動化・最適化できるはずの非生産的作業に費やしていると報告している。CIの待ち時間は、その中でも最も目に見えやすい損失だ。

テストが肥大化する4つのパターン

CIが遅い原因は分かった。では、なぜテストはここまで膨れたのか。対策を打つ前にこの問いに答えておく必要がある。原因を把握せずに高速化しても、テストはまた膨れる。現場で繰り返し見てきた4つのパターンを示す。

パターン1: E2Eテストの増殖

「UIの動作保証にはE2Eが必要」という正論のもと、E2Eテストが際限なく増える。Google Testing Blogの有名な記事「Just Say No to More End-to-End Tests」が指摘する通り、E2Eは本質的にスケールしない。外部依存(DB、外部API、ブラウザ)が多いため実行時間が長く、フレイキー(不安定)にもなりやすい。フレイキーとは、コードを変更していないのにテストが成功したり失敗したりする現象で、ネットワーク遅延、非同期処理のタイミング、テスト間のデータ汚染などが主な原因だ。

見分け方: テストスイートの実行時間内訳を出して、E2Eが全体の30%以上を占めていたら赤信号。

パターン2: テストデータの過剰生成

1つのテストケースでテストデータ生成ライブラリ(RubyならFactoryBot、Pythonならfactory_boy、JavaならTestEntityManagerやテスト用Builderクラス)を10個も20個も呼んでいる。「念のため」で作ったデータが積み重なり、DB操作だけで数秒かかるテストが大量にできる。

Javaの現場でありがちなのが、Spring Bootの@SpringBootTestで毎回アプリケーションコンテキスト全体を起動し、JPAで大量のエンティティをsave()するパターンだ。本当に必要なのは1つのServiceクラスの検証なのに、DIコンテナの初期化だけで数秒かかる。

見分け方: テスト実行中のDBクエリ数をプロファイリングする。「1件のデータを確認するだけなのに数十クエリ走っている」ケースは、テストが検証に不要なデータまで生成している証拠だ。

パターン3: テスト間の暗黙的依存

テストの実行順序に依存しているケース。「Aのテストが先に実行されてDBにデータが入っている前提でBが動く」という状態。並列化しようとした途端に大量に落ちる。

見分け方: テストをランダム順序で実行してみる。落ちるテストがあれば依存がある。

パターン4: 死んだテストの蓄積

機能が削除されたのにテストだけ残っている。誰も消さないまま年単位で実行され続ける。「消すと何か壊れるかもしれない」という恐怖で放置される。

見分け方: カバレッジレポートで、テストが触っているコードがすでに本番で使われていないものがないか確認する。

5段階の処方箋——優先順位が命

ここからが本題。「CIが遅い」と言われたとき、いきなり並列化に手を出すチームが多い。だが、並列化はStep 3だ。先にやるべきことがある。

Step 1: 無駄を削る(即効性◎、コスト低)

最もコスパが高い。テストコードを1行も書かずにCI時間を短縮できる。

やること:

アクション 期待効果 判断基準
フレイキーテストの隔離 再実行の無駄を排除 直近1ヶ月で2回以上失敗→再成功したテスト
死んだテストの削除 実行時間の純減 対応する機能コードが削除済み
不要なsetup処理の削除 DB操作時間の短縮 テストが使っていないFactoryデータ
テスト前のオーバーヘッド削減 ビルド全体の短縮 アセットコンパイルやmigrationの条件分岐化

Shopifyの事例がStep 1の効果を如実に示している。CI全体の大半がテスト実行前のオーバーヘッド(アセットコンパイル、DB migration、bundle installなど)に費やされていた。「DBスキーマを変更するPRはごく一部」という実態を把握し、変更がないPRではそのステップを完全スキップすることで、40分→10分以下に短縮した。

フレイキーテストの深刻さ: Trunk.ioが2,020万CIジョブを分析した調査によれば、開発者の**59%が月1回以上フレイキーテストに遭遇し、Microsoftの調査ではフレイキーテストが生産性を最大35%**低下させるとされている。フレイキーテストの放置は、CI時間の問題以前にチームの信頼を壊す。

フレイキーテストの隔離方法(GitHub Actions の例)
# .github/workflows/test.yml
jobs:
  stable-tests:
    runs-on: ubuntu-latest
    steps:
      - run: pytest -m "not flaky" --timeout=60

  flaky-tests:
    runs-on: ubuntu-latest
    continue-on-error: true  # 失敗してもPRをブロックしない
    steps:
      - run: pytest -m "flaky" --count=3  # 3回再実行

テストに @pytest.mark.flaky を付けて隔離する。ポイントは continue-on-error: true で本体のCIをブロックしないこと。ただし、隔離は一時措置であり、根本原因の修正が本筋。

フレイキーテストの隔離方法(GitLab CI の例)
# .gitlab-ci.yml
stable-tests:
  stage: test
  script:
    - pytest -m "not flaky" --timeout=60
  rules:
    - if: $CI_PIPELINE_SOURCE == "merge_request_event"

flaky-tests:
  stage: test
  script:
    - pytest -m "flaky" --count=3
  allow_failure: true  # 失敗してもMRをブロックしない
  rules:
    - if: $CI_PIPELINE_SOURCE == "merge_request_event"

GitLab CIでは allow_failure: true が GitHub Actions の continue-on-error: true に相当する。Javaプロジェクト(JUnit 5)の場合は、@Tag("flaky") を付けてGradleやMavenのテスト設定で除外する。

// JUnit 5 でフレイキーテストにタグを付ける
@Tag("flaky")
@Test
void testExternalApiIntegration() {
    // ネットワーク依存で不安定なテスト
}
// build.gradle - フレイキーテストを通常実行から除外
test {
    useJUnitPlatform {
        excludeTags 'flaky'
    }
}

// フレイキーテスト専用タスク
tasks.register('flakyTest', Test) {
    useJUnitPlatform {
        includeTags 'flaky'
    }
    ignoreFailures = true  // 失敗してもビルドを止めない
}

やらない判断: テストの削除は怖い。だが「このテストが守っている機能はまだ存在するか?」を基準にすれば判断できる。機能が消えているなら、テストを残す理由はない。

Step 2: テストピラミッドを正す(根本治療、コスト中)

Step 1で即効性のある無駄を削ったら、次はテスト構造そのものを見直す。

テストピラミッドの推奨比率は以下の通りだ。ユニットテストが多いほどCIが速くなる理由は単純で、ユニットテストはDB接続もブラウザ起動も不要だから桁違いに速い。

レイヤー 推奨比率 実行時間の目安 カバーすべき範囲
ユニット 60-70% 1テスト < 100ms ビジネスロジック、バリデーション
統合 20-30% 1テスト < 5秒 API、DB操作、外部サービス連携
E2E 5-10% 1テスト < 30秒 クリティカルなユーザーフロー

実態はこうだ。多くのチームは気づかないうちにこのピラミッドを逆転させている。「ユニットテストだと不安だからE2Eで確認する」という心理が積み重なり、E2Eが全テストの40%を超えているプロジェクトは現場で頻繁に見かける。

具体的なアクション:

  1. テスト実行時間の内訳を可視化する: レイヤー別に実行時間を集計し、どこに時間がかかっているかを数字で把握する
  2. E2Eテストのトリガーを分離する: 全PRで実行するのではなく、以下のタイミングに限定する
    • mainブランチへのマージ時
    • ナイトリービルド(毎日深夜)
    • e2e ラベルが付いたPR
  3. E2Eで検証していた内容をユニット/統合に移す: E2Eで「バリデーションエラーが出ること」を確認しているなら、それはユニットテストの仕事

Step 3: 並列化する(スケール◎、コスト中〜高)

Step 1-2で構造を正した上で、並列化を導入する。順番が重要だ。無駄なテストを並列化しても、無駄が並列に走るだけで意味がない。

言語・ツール別の並列化方法:

言語/ツール 方法 コマンド例
Python (pytest) pytest-split pytest --splits 4 --group 1
JavaScript (Jest) --shard jest --shard=1/4
Ruby (RSpec) parallel_tests parallel_rspec spec/
Java (Maven) maven-surefire-plugin mvn test -Dsurefire.forkCount=4
Java (Gradle) maxParallelForks gradle test --max-workers=4
Go -p(パッケージ並列) go test -p 4 -count=1 ./...
汎用 (CircleCI) circleci tests split circleci tests split --split-by=timings

GitHub Actions での並列化例:

jobs:
  test:
    strategy:
      matrix:
        shard: [1, 2, 3, 4]
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - run: npm ci
      - run: npx jest --shard=${{ matrix.shard }}/4

GitLab CI での並列化例:

# .gitlab-ci.yml
test:
  stage: test
  parallel: 4  # GitLab が自動で4ジョブに分割
  script:
    - >
      pytest
      --splits $CI_NODE_TOTAL
      --group $CI_NODE_INDEX
      --splitting-algorithm least_duration

GitLab CIは parallel キーワードでジョブを自動分割し、CI_NODE_TOTAL(総並列数)とCI_NODE_INDEX(何番目か)を環境変数で渡してくれる。GitHub Actionsのmatrix strategyに比べて記述量が少ない。

Java(Gradle + JUnit 5)の並列化設定
// build.gradle
test {
    useJUnitPlatform()

    // JVMレベルの並列化: テストクラスを別プロセスで実行
    maxParallelForks = Runtime.runtime.availableProcessors().intdiv(2) ?: 1

    // JUnit 5 レベルの並列化: テストメソッドを並列実行
    systemProperty 'junit.jupiter.execution.parallel.enabled', 'true'
    systemProperty 'junit.jupiter.execution.parallel.mode.default', 'concurrent'
    systemProperty 'junit.jupiter.execution.parallel.mode.classes.default', 'concurrent'
}

maxParallelForks はテストクラス単位でJVMプロセスを分ける。JUnit 5の並列実行はテストメソッド単位で並列化する。両方を組み合わせると効果が高いが、テストがDB状態を共有している場合は @Execution(ExecutionMode.SAME_THREAD) で個別に直列実行に戻す必要がある。

GitLab CIでGradleプロジェクトを並列化する場合:

# .gitlab-ci.yml
test:
  stage: test
  parallel: 3
  script:
    - ./gradlew test -Dsurefire.forkCount=$CI_NODE_TOTAL
  artifacts:
    reports:
      junit: build/test-results/test/*.xml  # テスト結果をGitLabのMR画面に表示

pytest-splitを使った事例では、500秒のテストスイートを10並列に分割して50秒まで短縮した報告がある。

並列化の限界を知っておく:

  • GitHub Actions の同時実行上限はプランによって異なる(Free: 20並列、Team: 60並列、Enterprise: 180並列)。Publicリポジトリは無料でも並列数の制限が大幅に緩和される
  • 並列数を増やすほどCI料金は線形に増加する
  • テスト間に暗黙的依存があると、並列化した途端に落ちる(Step 1のパターン3)
  • ジョブの起動オーバーヘッド(コンテナ起動、依存インストール)が1ジョブあたり30〜60秒かかる。4並列なら合計2〜4分の起動コストが上乗せされるため、テスト自体が短い場合は逆効果になる

判断基準: 4並列で十分な効果が出なければ、並列数を増やすよりStep 4に進む方が費用対効果が高い。

Step 4: 影響範囲で絞る(長期効果◎、コスト高)

全テストを毎回実行するのではなく、コード変更に影響を受けるテストだけを実行する。Test Impact Analysis(TIA)と呼ばれる手法だ。

Martin Fowlerが「The Rise of Test Impact Analysis」で体系的に解説している。

2つのアプローチ:

アプローチ 仕組み 精度 導入コスト 代表的なツール
静的解析 importやrequireの依存関係を解析し、変更ファイルに依存するテストを特定 中(実行時に決まる依存を見逃す) 低〜中 Nx affected, dorny/paths-filter
動的解析 テスト実行時にどのコードが実行されたかを記録し、変更コードに関連するテストだけを選択 中〜高 Datadog TIA, Azure DevOps TIA

Monorepo なら nx affected が最も手軽:

# 変更されたパッケージとその依存先だけテスト
npx nx affected --target=test --base=main

Monorepoの事例では、Nx + リモートキャッシュの組み合わせでタスクの80%をスキップし、CI時間を4分まで短縮した報告がある。

Monorepoでない場合の選択肢:

  • Datadog Test Impact Analysis: CIプロバイダー非依存のSaaS。動的解析方式で精度が高い
  • Azure DevOps TIA: Azure Pipelines組み込み。Microsoft系のスタックなら自然な選択肢
  • 自前実装: git diff + ファイル依存グラフで簡易的なTIAを構築する
簡易的なTIAの自前実装例(GitHub Actions)
jobs:
  detect-changes:
    runs-on: ubuntu-latest
    outputs:
      backend: ${{ steps.filter.outputs.backend }}
      frontend: ${{ steps.filter.outputs.frontend }}
    steps:
      - uses: dorny/paths-filter@v3
        id: filter
        with:
          filters: |
            backend:
              - 'src/api/**'
              - 'src/models/**'
              - 'src/services/**'
            frontend:
              - 'src/components/**'
              - 'src/pages/**'

  test-backend:
    needs: detect-changes
    if: needs.detect-changes.outputs.backend == 'true'
    runs-on: ubuntu-latest
    steps:
      - run: pytest tests/api/ tests/models/ tests/services/

  test-frontend:
    needs: detect-changes
    if: needs.detect-changes.outputs.frontend == 'true'
    runs-on: ubuntu-latest
    steps:
      - run: npx jest --testPathPattern="(components|pages)"

dorny/paths-filter で変更されたファイルパスを検出し、関連するテストジョブだけを実行する。完全なTIAではないが、導入コストが低く効果は大きい。

簡易的なTIAの自前実装例(GitLab CI)
# .gitlab-ci.yml
test-backend:
  stage: test
  script:
    - ./gradlew :api:test :service:test
  rules:
    - changes:
        - "src/main/java/com/example/api/**/*"
        - "src/main/java/com/example/service/**/*"
        - "src/main/java/com/example/model/**/*"

test-frontend:
  stage: test
  script:
    - npx jest --testPathPattern="(components|pages)"
  rules:
    - changes:
        - "src/components/**/*"
        - "src/pages/**/*"

# mainブランチへのマージ時は全テスト実行(安全弁)
test-all:
  stage: test
  script:
    - ./gradlew test
  rules:
    - if: $CI_MERGE_REQUEST_TARGET_BRANCH_NAME == "main"

GitLab CIは rules: changes で変更ファイルに基づくジョブの条件実行をネイティブにサポートしている。GitHub Actionsの dorny/paths-filter に相当する機能がビルトインで使えるのはGitLabの強みだ。

やらない判断: TIAは万能ではない。設定ミスで影響のあるテストがスキップされると、バグが本番に出る。よくあるミスは「設定ファイル(.envconfig/)の変更が影響範囲に含まれていなかった」「共有ライブラリの変更がスコープから漏れた」というケースだ。安全側に倒すなら、mainブランチへのマージ前には全テストを実行するルールを併用する。GitHub Actionsユーザーなら、まず dorny/paths-filter による簡易TIAから始めるのが最も手軽な入口だ。

Step 5: キャッシュとインフラ(仕上げ、コスト低〜中)

Step 1-4が構造的な改善なら、Step 5は実行環境の最適化だ。

依存ライブラリのキャッシュ:

# GitHub Actions での npm キャッシュ(yarn なら cache: 'yarn'、pnpm なら cache: 'pnpm' に変更)
- uses: actions/setup-node@v4
  with:
    node-version: 20
    cache: 'npm'
# GitLab CI でのキャッシュ(Gradle の例)
test:
  stage: test
  cache:
    key: gradle-${CI_COMMIT_REF_SLUG}
    paths:
      - .gradle/caches
      - .gradle/wrapper
  script:
    - ./gradlew test

これだけで依存ライブラリのダウンロード時間が大幅に短縮される。GitHub Actionsの cache: 'npm'package-lock.json の存在が前提。GitLab CIは cache キーワードでパスを明示的に指定する方式だ。Maven なら ~/.m2/repository、pip なら ~/.cache/pip をキャッシュする。

Docker レイヤーキャッシュ:

CIでDockerイメージをビルドしている場合、レイヤーキャッシュの活用でビルド時間を大幅に短縮できる。COPY package.jsonCOPY . . より先に書くだけで、依存が変わらない限りキャッシュが効く。

ランナーのスペックアップ:

GitHub Actions の ubuntu-latest は2コア7GBメモリ。テストが重い場合、ubuntu-latest-4-cores(4コア16GB)や WarpBuild などの高速ランナーサービスを検討する。コストは上がるが、開発者の待ち時間×人数×時給と比較して判断する。ただし、サードパーティのランナーサービスを使う場合はセキュリティ審査(コードがどこで実行されるか、シークレットの扱い)が必要になることがある。

判断基準: ランナーのスペックアップは最後の手段。構造の改善なしにスペックを上げても、テストが増えればまた遅くなる。

各ステップの効果と所要期間

Step 効果の目安 導入にかかる期間 CI料金への影響
Step 1: 無駄を削る 20-40%短縮 1-3日 減少
Step 2: ピラミッドを正す 30-50%短縮 1-2週間 減少
Step 3: 並列化 50-75%短縮 1-3日 増加(並列数分)
Step 4: TIA 60-80%短縮 1-4週間 減少
Step 5: キャッシュ/インフラ 10-30%短縮 数時間〜1日 状況による

現場で遭遇する罠

罠1: 並列化したらテストが壊れた

テスト間に暗黙的依存がある場合、並列化した途端に大量のテストが落ちる。「テストAがDBにデータを入れ、テストBがそれを前提に動く」というパターンが典型的だ。

対策: 並列化の前に、テストをランダム順序で実行してみる。

# pytest(pytest-randomly プラグインが必要)
pytest -p randomly

# RSpec
rspec --order random

# Jest(jest-random-order プラグインを使う。ビルトインのランダム実行はない)
# npm install --save-dev jest-random-order
jest --testSequencer=jest-random-order/sequencer

# JUnit 5(junit-platform.properties に設定を追加)
# src/test/resources/junit-platform.properties
# junit.jupiter.testmethod.order.default=org.junit.jupiter.api.MethodOrderer$Random
# junit.jupiter.testclass.order.default=org.junit.jupiter.api.ClassOrderer$Random

落ちるテストがあれば、先にそれを修正する。この手順をスキップして並列化すると、修正に倍の時間がかかる。

罠2: CI時間だけ見て品質を落とす

「テストを減らせばCIは速くなる」は正しいが、減らし方を間違えるとバグが本番に出る。

判断基準: テストを削除・スキップするときは以下を確認する。

  1. このテストが守っている機能はまだ存在するか? → Noなら削除
  2. 同じことを検証している別のテストがあるか? → Yesなら重複を削除
  3. E2Eで検証している内容をユニットテストに移せるか? → Yesなら移行して削除
  4. 上記すべてNoなら → 削除してはいけない

罠3: 「全部入り」の設定ファイル

CIの設定ファイルが1つの巨大なYAMLになっていて、全ジョブが直列に実行されているケース。ジョブ間の依存関係を整理し、独立したジョブは並列に走らせるだけで効果がある。

罠4: 高速化を一度やって終わりにする

CIの高速化はプロジェクトの成長とともに継続的に取り組む必要がある。「3ヶ月前に並列化したのに、また15分になった」——これはチームの成長とテストの増加が同時進行している証拠であり、構造的な問題を放置したまま対症療法を繰り返した結果だ。

対策: CIの実行時間をダッシュボードで監視し、閾値(例: 8分)を超えたらアラートを出す。GitHub Actionsならジョブのサマリーから実行時間を取得してSlackに通知する仕組みが手軽に作れる。

まとめ: 明日から始める最初の一歩

CIのテスト肥大化に対処するとき、最も重要なのは優先順位だ。

  1. まず計測する: GitHub Actionsの各ステップの実行時間サマリーや、Buildkite(Shopifyも使っているCIサービス)等のダッシュボードでジョブ別の実行時間を把握する。どこに時間がかかっているか分からないまま最適化しても効果は出ない
  2. 無駄を削る: フレイキーテストの隔離、死んだテストの削除、不要なオーバーヘッドの排除。コード変更なしで20-40%短縮できることが多い
  3. テストピラミッドを点検する: E2Eの比率が30%を超えていたら、ユニット/統合への移行を計画する
  4. 構造が正しくなった上で並列化する: pytest-split、Jest --shard、Gradle maxParallelForks、GitLab CI parallel など、ツールは豊富にある
  5. TIAで影響範囲を絞る: Monorepoならnx affected、GitHub Actionsならdorny/paths-filter、GitLab CIならrules: changesから始める

最初の一歩として推奨するのは、CIジョブの実行時間内訳を可視化することだ。GitHub Actionsなら各ステップの実行時間がサマリーに出るので、それをスプレッドシートに転記するだけでもいい。「何に時間がかかっているか」が分かれば、5段階のどこから手を付けるべきかは自ずと見える。

参考リンク

GitHubで編集を提案

Discussion