はじめに
こんにちは、タイミーのプラットフォームエンジニアリングチームに所属している徳富(@yannKazu1)です。
この記事では、スキマバイトサービス「タイミー」のバックエンド(Rails API)に Datadog Test Impact Analysis(TIA) を導入し、PRごとのCIフィードバックを大幅に高速化した取り組みについて紹介します。
なお、バックエンドはRailsアプリケーションなので、テストフレームワークは RSpec を前提として話を進めます。
サービスが成長してプロダクトの機能が増えてくると、テストも当然増えていきますよね。開発者が増えればPRの数も増え、CIの待ち時間がボトルネックになってくるのも、割とあるあるな課題だと思います。同じような悩みを抱えているチームの参考になれば嬉しいです。
背景:膨れ上がるテストとの戦い
タイミーのバックエンドはモジュラーモノリスを採用しており、複数のインターフェースを1つのRailsアプリケーションで提供しています。
担当するバックエンドエンジニアの数もかなり多く、当然テストの数もそれに比例して増えていきます。
テスト数は約35,000。そして月に約2,000テストのペースで増加していました。
さらに最近はAIコーディングツールの活用も進み、テストが増えるペースが加速しています。人手に加えてAIコーディングツールの活用も進む中で、テストの増加速度が既存のチューニングでは吸収しきれない状況になりつつありました。

これまでの高速化の取り組み
もちろん手をこまねいていたわけではありません。たとえば、self-hosted runnerの活用、GitHub Actionsのjobの35並列分割、split-test による実行時間ベースの均等分割、ジョブ内のステップ並列実行(Redis/ESの起動とRuby/bundleセットアップをbackground + wait-allで同時進行)、MySQLマイグレーションキャッシュ、Dockerイメージキャッシュなど——泥臭いチューニングを積み重ねてきました。その結果、35,000テストを約10分で完走させるところまで持っていくことができました。
しかし、毎月2,000テストずつ増えていく状況では、並列数を増やし続けるのにも限界があります。ノード数を増やせばその分CIコストも増えていきますし、どこかで別のアプローチが必要になるのは明らかでした。
今後の成長を考えると、すべてのPRですべてのテストを実行するのは持続可能ではない と判断しました。
Datadog Test Impact Analysis(TIA)という選択肢
そこで目をつけたのが Datadog Test Impact Analysis(TIA) です。
TIAは、コード変更の差分を解析し、その変更に関係のあるテストだけを選択的に実行する仕組みです。Datadogが各テストのコードカバレッジを収集・保持しており、「このファイルを変更したなら、このテストを実行すべき」という判断を自動で行ってくれます。
これにより、PRごとのCIでは 変更に関連するテストだけ を実行してフィードバックを高速化しつつ、品質の担保は別の仕組みで行うという戦略が取れるようになります。
TIAを使うための前提条件
TIAを使うには、まず Test Optimization を導入しておく必要があります。Test Optimizationはテスト結果やパフォーマンスデータをDatadogに送信して可視化する仕組みで、TIAはその上に乗っかる機能です。
具体的には以下が必要になります。
- Test Optimization の設定が完了していること:
datadog-cigem(>= 1.0)を導入し、CIからテスト結果をDatadogに送信できる状態にしておく - Datadog側でTIAを有効化すること: Test Service Settingsページから、
Intelligent Test Runner Activation権限を持つユーザーがTIAを有効にする必要がある
弊社ではもともとTest Optimizationを使ってテストの実行結果をDatadogで可視化していたので、TIAの導入自体は追加コストなしでスムーズに進められました。
設計方針:GitHub Flow + マージキューを活かす
ここからが今回のキモです。
弊社は基本的に GitHub Flow を採用しています。mainにマージされるとリリースが走るというシンプルな方針です。そして、mainへのマージには マージキュー(Merge Queue) を利用しています。マージキューの導入については、こちらの記事で詳しく紹介しています。
全体のフローはこんな感じです。
flowchart TD
A["featureブランチで開発・push"] --> B["PR CI(ci_branch)<br/>TIA有効 → 関連テストのみ実行<br/>⚡ 高速フィードバック"]
B -->|CI通過| C["レビュー & Approve"]
C --> D["マージキューに投入"]
D --> E["マージキュー CI(ci)<br/>全テスト実行<br/>+ Datadogカバレッジ収集"]
E -->|全テスト通過| F["mainにマージ"]
F --> G["リリース"]
style B fill:#e8f5e9,stroke:#4caf50
style E fill:#fff3e0,stroke:#ff9800
ポイントは、PRのCIとマージキューのCIで役割を分けている ところです。この「マージキュー」の特性をうまく活かすことで、以下のような構成を実現しました。
PRブランチ(ci_branch ワークフロー)
- TIAを有効化して、差分に関連するテストのみを実行
- 開発者へのフィードバックを高速化
# ci_branch.yml jobs: ci: uses: ./.github/workflows/_ci.yml with: skip: false itr_enabled: true # TIAによるテストフィルタリングを有効化 secrets: inherit
マージキュー(ci ワークフロー merge_group イベント)
- 全テストを実行(TIAフィルタリングは無効)
- Datadogへのカバレッジ収集も同時に実施
- これを通過しないとmainにマージされない
# ci.yml on: merge_group: jobs: ci: uses: ./.github/workflows/_ci.yml with: skip: ${{ github.event_name != 'merge_group' }} # マージキューでは全テストを実行しつつカバレッジを収集 dd_coverage_enabled: true tia_test_skipping_mode: suite tia_force_run_all: true # gem側のスキップも無効化して全spec実行 secrets: inherit
(コード内の tia_test_skipping_mode: suite は、テストの選定を「テストファイル単位」で行う設定です。なぜ suite にしたのかは、後述の「suiteモードを選んだ理由」で詳しく触れます。)
つまり、PRでは「速さ」を、マージキューでは「安全」を という役割分担です。
マージキューのテストを全件パスしないとmainにマージされず、mainにマージされないとリリースされない。この構造があるからこそ、PRのテストを絞っても品質を担保できるわけです。
カバレッジ収集はマージキューで
TIAの精度を保つには、カバレッジデータを最新に保つ必要があります。
マージキューは mainにマージされる直前のコミットで全テストを実行 するので、ここでカバレッジを収集すれば常に最新の状態が保たれます。
# ci.yml(merge_group イベント時) dd_coverage_enabled: true # カバレッジ収集を有効化 tia_test_skipping_mode: suite # suite単位で収集 tia_force_run_all: true # gem側のスキップも無効化して全spec実行
当初はこのカバレッジ収集を別ワークフロー(Coverage Build)でも回していたのですが、マージキューで毎回「全テスト実行+カバレッジ収集」を行う構成にしたことで一本化でき、運用がシンプルになりました。
ddtest plan によるテスト選定と分割の工夫
ddtest plan の基本
TIAによるテスト選定には、Datadogが提供する ddtest CLIツールを使っています。
./ddtest plan \ --platform ruby \ --framework rspec \ --min-parallelism 1 \ --max-parallelism 1 \ --test-skipping-mode suite \ --tests-location "**/*_spec.rb" \ --tests-exclude-pattern "vendor/**"
ddtest plan はDatadog APIと通信して、現在のコミットの差分に対してスキップ可能なテストを判定し、実行すべきテストファイルの一覧を .testoptimization/runner/test-files.txt に出力します。
gem の環境変数スキップではなく ddtest plan を採用した理由
実は ddtest コマンドを使わなくても、datadog-ci gemを導入していれば、環境変数 DD_CIVISIBILITY_ITR_ENABLED=true の設定だけでTIAによるテストスキップを有効にできます。最も手軽な方法です。
ただ、弊社ではこの方法を採用しませんでした。理由は 35並列との相性が悪い からです。
gemの組み込みスキップは、RSpecの実行時に各テストケースを個別にスキップします。そのため、35ノードにテストを分割した後に各ノード内でスキップが走るので、「このノードは割り当てられたテストの大半がスキップされて30秒で終わったけど、別のノードは全部実行対象で5分かかった」といったことが起きます。結果としてノード間のばらつきが大きくなり、並列化の効率がガクッと落ちるんですよね。
そこで、テストの選定は ddtest plan で 実行前に 行い、選定されたファイルだけを split-test で均等に分割する、という方式を取りました。スキップの判断をRSpec実行の外に出すことで、各ノードに割り当てるテスト量を事前にコントロールできるようにしています。
ちなみに ddtest plan 自体にも --min-parallelism / --max-parallelism オプションによるノード分割機能があります。ただ、検証してみたところ split-test と比べてノード間の実行時間のばらつきが大きかったため、分割は引き続き split-test に任せる構成にしました。ddtest plan は「どのテストを実行するか」の選定だけに使い、「どう分割するか」は split-test に委ねる、という役割分担です。
カバレッジ収集時の罠と DD_TIA_FORCE_RUN_ALL
もう一つ、DD_CIVISIBILITY_ITR_ENABLED 周りで地味にハマったポイントがあります。
TIAが正しくテストを選定するには、各テストがどのソースコードを通過するかという カバレッジデータ をDatadogに送る必要があります。カバレッジ収集は DD_CIVISIBILITY_ITR_ENABLED=true で有効化できます。ただし、この環境変数を true にすると、カバレッジ収集と同時にテストのスキップも有効になります。
つまり、カバレッジを集めたいだけなのに、TIAが「このテストはスキップしてOK」と判断したテストのカバレッジが収集できないという矛盾が起きます。これだとカバレッジデータに穴が空き、次回以降のTIA判定精度が落ちていきます。
この問題を解決するために、datadog-ci gem が提供する datadog_itr_unskippable というRSpecメタデータを活用しています。
datadog-ci gem は、DD_CIVISIBILITY_ITR_ENABLED=true のときにDatadog APIからスキップ可能なテストの一覧を取得し、該当するテストをスキップします。しかし、RSpecのメタデータに datadog_itr_unskippable: true が設定されているテストは、スキップ対象から除外されて必ず実行されます。
これを利用して、spec_helper.rb で以下のように 全テストにunskippableメタデータを付与 しています。
# spec_helper.rb if ENV['DD_TIA_FORCE_RUN_ALL'] == 'true' # DD_CIVISIBILITY_ITR_ENABLED はカバレッジ収集と suite スキップを同時に有効化するため、 # 収集専用ジョブでは全 example を unskippable にして forced run させる RSpec.configure do |config| config.define_derived_metadata do |metadata| metadata[:datadog_itr_unskippable] = true end end end
define_derived_metadata はRSpecの機能で、全テストのメタデータにデフォルト値を追加できます。これにより、gem側のスキップ判定が働いても「このテストはスキップ不可」と判定されるので、結果的に全テストが実行されます。
# マージキューでの環境変数設定 DD_CIVISIBILITY_ITR_ENABLED: true # カバレッジ収集を有効化 DD_TIA_FORCE_RUN_ALL: true # 全テストをunskippableにして必ず実行
DD_CIVISIBILITY_ITR_ENABLED=true でカバレッジ収集の仕組みを有効にしつつ、DD_TIA_FORCE_RUN_ALL=true で全テストにunskippableメタデータを付与してスキップを防ぐ。これにより、全テストを実行して完全なカバレッジデータを収集できるようになります。
マージキューではこの組み合わせを使い、PRブランチでは DD_CIVISIBILITY_ITR_ENABLED=false(カバレッジ収集もスキップも無効)にして余計なオーバーヘッドを排除しています。
suiteモードを選んだ理由
TIAには testモード と suiteモード の2つの粒度があります。
- testモード: 個々のテストケース(
itブロック)単位でスキップを判定 - suiteモード: テストファイル(
_spec.rb)単位でスキップを判定
今回は suiteモード を採用しました。
理由はシンプルで、testモードでは35,000テストの分割判定に約2分かかるためです。差分が小さいPRなら問題ありません。しかし全テストに影響する変更(例えば spec_helper.rb の変更など)をした場合は、2分かけて「全テスト実行」という結論になり、かえってオーバーヘッドが大きくなってしまいます。その点、suiteモードなら対象がファイル単位なので、この判定が高速(5s程度)に完了します。
split-test との組み合わせ
前述の通り ddtest plan で選定したファイルを split-test で均等分割するのですが、ここが地味に苦労したポイントです。
split-test はファイルリストを直接受け取れず --tests-glob しか受け付けません。そこで、以下のようにシンボリックリンクを使ってglobのマッチ対象をTIA対象ファイルだけに制限するアプローチを取りました。
# TIA対象ファイルのシンボリックリンクを一時ディレクトリに作成 SPLIT_DIR=$(mktemp -d) while IFS= read -r f; do [ -f "$f" ] && mkdir -p "$SPLIT_DIR/$(dirname "$f")" && ln -s "$WORKSPACE/$f" "$SPLIT_DIR/$f" done < .testoptimization/runner/test-files.txt # 一時ディレクトリ内で split-test を実行(TIA対象のみが均等に分割される) (cd "$SPLIT_DIR" && "$WORKSPACE/split-test" \ --junit-xml-report-dir "$WORKSPACE/tmp/rspec_junit" \ --node-index $NODE_INDEX \ --node-total 35 \ --tests-glob "spec/**/*_spec.rb" \ --tests-glob "packs/*/spec/**/*_spec.rb")
ちょっとトリッキーですが、これにより TIAで絞り込んだテストを、実行時間ベースで均等に35分割する ことが実現できました。
なお、35並列のうちTIAの絞り込みで対象テストがゼロになるノードも出てきます。その場合は空のダミーJUnitレポートを出力しておき、後続のジョブが正常に動くようにしています。
PRの全変更を正しく評価するための工夫
もう一つ、導入時にハマったポイントがあります。
ddtest はHEADコミットのdiffだけを見てテスト選定を行います。PRに複数コミットがある場合、最後のコミットの変更しか考慮されません。これだと初期コミットで変更したファイルに関連するテストがスキップされてしまう可能性があります。
そこで、GitHub APIで merge-base(分岐点) を取得し、PRの全変更を1つのdiffとして ddtest に認識させる仕組みを入れています。
# PRの分岐点を取得
MERGE_BASE=$(gh api "repos/$GITHUB_REPOSITORY/compare/main...$GITHUB_SHA" \
--jq '.merge_base_commit.sha')
# 分岐点を親、現在のツリーを内容とするコミットを作成
git fetch --depth=1 origin "$MERGE_BASE" --no-tags
TREE=$(git rev-parse "HEAD^{tree}")
TIA_COMMIT=$(git commit-tree "$TREE" -p "$MERGE_BASE" -m "TIA: all PR changes")
git reset --soft "$TIA_COMMIT"
これにより、HEADのdiffが「PR全体の変更」を表すようになり、TIAが正しく全変更を評価できるようになります。
全体のアーキテクチャまとめ
最終的な構成を図にすると以下のようになります。
flowchart TD
A[開発者がPRを作成] --> B
subgraph PR["ci_branch ワークフロー(PRブランチ)"]
B["ddtest plan (TIA)\n関連テストのみ抽出"] --> C["split-test\n35並列に均等分割"] --> D["RSpec実行\n(関連テストのみ)"]
end
D -->|テスト通過| E[マージキューに投入]
E --> F
subgraph MQ["ci ワークフロー(merge_group イベント)"]
F["全テスト実行\n(TIAフィルタリングなし)"] --> G["Datadogカバレッジ収集\n(TIA精度を維持)"]
end
G -->|全テスト通過| H["mainにマージ → リリース"]
style PR fill:#e8f5e9,stroke:#4caf50
style MQ fill:#fff3e0,stroke:#ff9800
導入時のハマりポイントまとめ
実際に導入してみて、いくつかハマったポイントをまとめておきます。
1. testモードの分割コスト
前述の通り、testモードでは35,000テストの分割判定に約2分かかります。suiteモードなら数秒で完了します。テスト数が多い場合はsuiteモード一択だと思います。
2. split-test との組み合わせ
split-test がファイルリストを直接受け取れないため、シンボリックリンクを使った一時ディレクトリ方式を採用しました。ちょっとトリッキーですが、確実に動きます。
3. マルチコミットPRの差分評価
ddtest がHEADコミットのdiffしか見ない問題は、merge-baseからのコミットを作り直すことで解決しました。
4. カバレッジの鮮度
TIAの精度はカバレッジデータの鮮度に依存します。当初は別ワークフロー(Coverage Build)でも収集していましたが、マージキューで毎回全テスト+カバレッジ収集を行う構成にしたことで一本化でき、シンプルになりました。
5. TIAで全テストがスキップされるノードの対策
35並列のうち、TIAで対象テストがゼロになるノードが出てきます。その場合はダミーのJUnitレポートを出力して後続のジョブが正常に動くようにしています。
成果
TIA導入前は、PRのCIで全テストを実行していたため 約10分 かかっていました。TIA導入後は変更の差分によって実行されるテスト数が変わりますが、差分が小さいPRでは 1〜2分 で完了するケースもあり、大幅に高速化しています。

おわりに
Datadog TIAの導入により、PRごとのテスト実行時間を大幅に短縮しつつ、マージキューで全テストを実行するという安全な構成を実現できました。
ポイントをまとめると:
- PRブランチ: TIAで関連テストのみ実行 → 高速フィードバック
- マージキュー: 全テスト実行 + カバレッジ収集 → 品質担保
- suiteモード: テスト数が多い場合はtestモードよりsuiteモードが実用的
- ddtest plan + split-test: TIAフィルタリングと均等分割の組み合わせがキモ
- GitHub Flow + マージキュー: この組み合わせがTIA導入の前提条件として非常にフィットした
テスト数がどんどん増えていく中で、「全部回す」から「必要なものだけ回す」へのシフトは避けて通れない道だと思います。同じような課題を抱えているチームの参考になれば幸いです。