RAKSUL TechBlog

RAKSULグループのエンジニアが技術トピックを発信するブログです

RubyKaigi 2026 セッションレポート - Ruby のテストを「賢くスキップ」する仕組み

こんにちは。プラットフォーム統括部の鍛冶です。先日、函館で開催された RubyKaigi 2026 に参加してきました。本記事では、その中で特に印象に残った Test Impact Analysis for Ruby(Datadog の Anton Marchenko 氏)のセッションを紹介します。

CI のテスト時間が肥大化していくのは、多くの Ruby/Rails プロジェクトの悩みです。本セッションでは、Ruby アプリケーション向けのテストインパクト分析(Test Impact Analysis、以下 TIA)ツールを実装するまでの試行錯誤が語られました。

このセッションは、「ある変更に対して、本当に走らせる必要のあるテストはどれか」を Ruby ランタイムを活用して判定するツールの開発記です。Ruby の低レベル API を駆使するという点でも面白いトピックでしたので、行カバレッジ・アロケーション追跡・バイトコード解析と段階的に積み上げていく流れを追っていきます。

TIA とは

TIA は、「ソースコード全体の依存関係グラフを使って、ある変更に対してどのテストを実行すべきかを判定する」手法です(Martin Fowler 氏の解説)。

具体例で考えてみます。あるテスト test_for_organization は、コントローラ A・モデル B・モデル C を実行します。この場合、A・B・C のいずれかが変更されたときは、このテストを走らせる必要があります。一方で、それ以外のファイルのみが変更されているなら、このテストはスキップしても安全だと判定できます。

graph LR
    Test["test_for_organization"]
    A["controllers/orgs.rb"]
    B["models/org.rb"]
    C["models/user.rb"]
    Test -->|depends on| A
    Test -->|depends on| B
    Test -->|depends on| C

このグラフを「テストごとに過不足なく作る」ことが TIA ツールの肝です。

設計の軸:パフォーマンス vs 正確性

TIA ツールを作るとき、避けて通れないトレードオフが 2 つあります。

1 つ目は パフォーマンス です。TIA はテスト全体を速くするためのものですが、依存関係を集めるため、テスト実行中にランタイムを計測する必要があり、計測自体は必ずテストを遅くします。あるユーザーは多くのテストをスキップできて速くなりますが、別のケースでは(差分が大きく)すべてのテストを実行することもあります。こうしたケースでも目立った速度低下を避けられなければ、ユーザーには使ってもらえません。Marchenko 氏は CI のテスト実行時間を 50% 以上削減する ことを目標にしていると述べていました。

以下の概念図は、発表で示されたベンチマークの典型例を筆者が図として整理し直したものです。計測のオーバーヘッドによりテストセッションは約 15% 遅くなりますが、半数のテストをスキップできるケースでは平均で 2 倍高速化されると述べられていました。個々のテスト実行は遅くなる一方で、CI 全体としては大幅に高速化される、という結果です。

graph TD
    A["元のテスト時間: 100%"]
    B["計測込みフル実行: 約 115%(発表値)"]
    C["半数スキップ+計測: 平均 2 倍速(発表値)"]
    A -.-> B
    A -.-> C

2 つ目は 正確性 です。これは要するに「ユーザーを困らせないこと」です。すべてのテストをスキップするようなツールはどんなに完璧に動いていても無意味ですし、本来落ちるはずのテストをスキップさせてしまえば信用は一瞬で失われます。Marchenko 氏は次の 2 つの方針を強調していました。第一に、あらゆるブランチ・テスト実行においてテスト依存関係マップを常に最新の状態に保つよう、TIA を継続的に動作させること。第二に、同じ入力に対しては同じテスト集合を返す、決定的(deterministic)なテスト選択を実現することです。

これらを念頭に、5 つのアプローチを順に追っていきます。

Approach 1: Ruby 標準の Coverage モジュール

最も自然なアプローチは「テストごとに行カバレッジを取り、あるソースの行が 1 つでも実行されればそのテストはそのソースに依存している」と判定する方法です。Ruby なら標準の Coverage モジュールが使えます。

API はシンプルです。

require "coverage"

Coverage.start
each_test do |test|
  before_each
  test.run
  result = Coverage.result(stop: false, clear: true) # テストごとの結果
  store_dependencies(test, result)
end

ところが、このやり方は 2 つの問題がありました。

  1. SimpleCov を壊してしまう。Ruby プロジェクトでカバレッジレポートの生成に使われている SimpleCov も、内部ではこの Coverage モジュールを利用しています。Coverage モジュールはプロセスにつき 1 つのグローバル状態しか持たないため、SimpleCov はテスト全体が終わった後にその累積結果を 1 回だけ読み取る、という使い方をしています。これに対して TIA はテスト単位の差分が必要なため、上のコードのように Coverage.result(stop: false, clear: true) でテストごとにカウンタをリセットせざるを得ません。その結果、テストスイート終了時には最後のテスト分しか結果が残らず、SimpleCov の合計カバレッジレポートが生成できなくなります。
  2. テストごとのカバレッジ取得に最適化されていないCoverage.result はプロジェクト内の全ファイル(gem 配下まで含む)のカバレッジを返します。そして、テストごとに全てのカバレッジを毎回フィルタすることになり、計算量が非常に大きくなります。

性能を測るのに発表者がよく使っていたのは Rubocop です。約 2 万のテストを持つ Ruby らしい大規模 OSS なので、ベンチマークに向いています。「オーバーヘッド」は「ツールを入れたときにテストが本来の何倍遅くなるか」という指標で、Coverage を使う方法では Rubocop で 約 4 倍 遅くなりました。

上記の問題から、このアプローチは見送られました。

Approach 2: TracePoint で自前のコードカバレッジ

Coverage API がダメなら、自分でカバレッジライブラリを書けばよいはずです。Ruby には TracePoint という API があり、特定のイベント(例: :line イベント)が発生するたびに呼び出されるコールバックを登録できます。この仕組みを活用すればカバレッジが計算可能です。

files = {}
trace = TracePoint.new(:line) do |tp|
  files[tp.path] = true
end

each_test do |test|
  trace.enable
  test.run
  trace.disable
  store_dependencies(test, files)
  files.clear
end

このアプローチには利点が 1 つあります。SimpleCov と完全に互換性がある点です。Coverage モジュール側の状態を奪わないので、SimpleCov の合計カバレッジレポートが壊れません。

ただし、性能は致命的でした。少なくとも 5 倍、ものによっては 6 倍遅いという結果であったため、このアプローチも見送られました。

Approach 3: C ネイティブ拡張で行カバレッジを書き直す

Ruby のオーバーヘッドを削るときの定番手段が C ネイティブ拡張 です(標準の Coverage API もこれで実装されています)。

Ruby の C API には、TracePoint と同種のイベントを受け取るためのフック関数があります(ruby/debug.h などで宣言されています)。これを使ってテスト開始時に :line イベントへのコールバックを登録し、テスト終了時に外す、という構造を C で書きます。

コールバックの中でやりたいのは「いま実行された行が、どのファイルに属するか」を取り出して記録することだけです。これには関数 rb_profile_frames(本来はプロファイラがバックトレース取得に使う API)を流用しています。

あとは、この呼び出しをいかに削減するかという問題です。2 つの最適化が効いています(実装は ext/datadog_ci_native/datadog_cov.c 参照)。

  • rb_profile_frames には先頭 1 フレームだけ要求する。本来プロファイラ用に深いスタックトレースを取れますが、TIA に必要なのは「いま実行中の行が属するファイル」だけなので、深さを最小限にして毎回の処理量を削ります。
  • 同じファイルでの連続発火をスキップする:line イベントは「行ごと」に発火しますが、TIA に必要なのは「このテストがこのファイルを踏んだ」という事実だけです。直前のソースファイル名のポインタを保持しておき、変わっていなければスキップします。

これらの工夫で rb_profile_frames の呼び出しを 30% 削減でき、TIAによるテスト時間の増加は 約 60% に収まりました。

落とし穴:行カバレッジでは捕捉できないケース

社内ベータで使い始めると、捕捉漏れが見つかりました。例として、ActiveModel ベースのフォームバリデーションを考えます。

# app/forms/user_form.rb
class UserForm
  include ActiveModel::Model

  attr_accessor :name, :email

  validates :name,  presence: true
  validates :email, presence: true,
                    format: { with: URI::MailTo::EMAIL_REGEXP }
end

# app/forms/user_form_spec.rb
RSpec.describe UserForm do
  it "is valid when all attributes are present" do
    expect(UserForm.new(name: "Alice", email: "a@example.com")).to be_valid
  end
end

このテストにおいて、UserForm のどの部分がカバレッジ対象になるでしょうか。SimpleCov で実行すると UserForm の全行が「カバレッジされた」と出ます。期待通りに見えます。

ところが、TIA ツールで動かすと このテストは UserForm を依存先として認識しません。範囲を広げて見ると、行カバレッジは UserForm ではなく activemodel gem の中 に飛んでいることが分かります。

理由は単純です。validates :foo, presence: true のような行は、確かに「カバレッジされている」のですが、実際にコードが実行されるのはアプリ起動時のクラスロード時 であって、テスト実行時ではないのです。テスト中に :line イベントが発火するのは ActiveModel のバリデーション実行ロジック側で、UserForm のバリデーション宣言行は発火しません。

つまりこの状態だと、UserForm にバリデーションを 1 つ追加・削除しても、このテストはスキップされてしまいます。バリデーションを直すたびにテスト全体を回さないと不安、という状態です。

Approach 4: アロケーションプロファイリング

行で取れないなら、何を取ればよいでしょうか。UserForm のテストは UserForm.new(...) という形で オブジェクトを生成し、その振る舞いを検証 しています。であれば「テスト中にどのクラスのインスタンスが生成されたか」を取れば、依存先が分かるのではないでしょうか。

Ruby には RUBY_INTERNAL_EVENT_NEWOBJ という 内部イベント があり、オブジェクト生成のたびに C 側のコールバックが呼ばれます。テスト中にアロケートされたクラスを集めておき、Ruby 標準の Module#const_source_location でクラス名から定義ファイルを引けば、UserFormapp/forms/user_form.rb のような対応が取れます。

これで UserForm のように「クラスロード時に DSL が走る」パターンでも、テスト中の UserForm.new(...) から app/forms/user_form.rb への依存が記録され、バリデーション宣言の追加・削除も正しく検知できるようになります。

行追跡にアロケーションプロファイリングを足したことで、当然性能は劣化します。ただし用途特化の最適化のおかげで許容範囲には収まりました。最悪ケースでオーバーヘッドが 60% から約 80% に増加しましたが、それでも 中央値は 20% 未満 に収まっています。受け入れ可能な範囲で、カバレッジ精度は大きく向上しました。

しかし、まだカバーできていないケースがあります。

Approach 5: 定数アクセスのバイトコード解析

例えば、次のようなケースがあります。

# app/models/limit.rb
class Limit
  SOME_LIMIT = 9000
end

# spec/models/limit_spec.rb
RSpec.describe Limit do
  it "is over 9000" do
    expect(Limit::SOME_LIMIT).to be > 9000
  end
end

SOME_LIMIT = 90009001 に変えたら、このテストは落ちるべきです。ところが TIA ツールはこのテストをスキップしてしまいます。

なぜでしょうか。

  • 定数アクセス専用の TracePoint イベントがない
  • 定数定義ファイルの行は実行されない(クラスロード時に実行済みで、:line イベントでも取れない)
  • 定数アクセスではオブジェクトのアロケーションが起きない(アロケーションプロファイラで取れない)

3 つのアプローチで取れていた依存関係をどれもすり抜けます。

AST は使えない、ではバイトコードは?

「コードを解析してテストがどの定数にアクセスしているかを見つける」のが筋でしょう。最初に思いつくのは AST(抽象構文木)です。新しい Ruby パーサ Prism の Playground で例を見ると、確かに「定数アクセス」を表すノードが見えます。

問題は、Ruby はランタイムに AST を保持していない ということです。コードが実行されるとき AST は捨てられています。テストごとにファイルを再パースすると数千秒の追加コストになり、性能影響が大きすぎます。

ところが、別の手があります。Ruby はバイトコードで動いています。Ruby プログラム起動時にコードはまず ISeq(instruction sequence、命令列)にコンパイルされ、Ruby VM がそれを実行します。Ruby には RubyVM::InstructionSequence というクラスがあって、バイトコードを覗くのに便利です。

iseq = RubyVM::InstructionSequence.compile("Limit::SOME_LIMIT > 9000")
iseq.to_a.flatten(1).grep(Array).select { |insn| insn[0] == :opt_getconstant_path }
# => [[:opt_getconstant_path, [..., :Limit, :SOME_LIMIT]]]

opt_getconstant_path という命令が「定数アクセスがどこで起きるか」を示しています。これがまさに必要な情報です。

生存中の ISeq を全スキャンする

幸い、Ruby は ランタイムに ISeq をメモリ上に保持しています(GC でいずれ回収はされるものの、メソッド本体の ISeq は長く残ります)。datadog-ci-rb は関数 rb_objspace_each_objects でヒープを走査して全 ISeq を集める C 拡張 (iseq_collector.c) を持っています。それを Ruby 側でスキャンし、opt_getconstant_path 命令から定数のパス(Foo::Bar::Baz のようなシンボル列)を取り出します(抽出ロジックは static_dependencies_extractor.rb 参照)。

集めた定数それぞれについて const_source_location を引き、「定数を使っているファイル」→「定数を定義しているファイル」 という静的依存関係を 1 本追加します。テスト本体ではなくソースファイル間の依存に組み込むことで、TIA モデルの整合性を保っています。

性能と効果

このアプローチの嬉しいところは、性能影響がほぼ無い ことです。複雑な定数解決ロジックを再実装したくなかったので Ruby 自身が持っている ISeq とキャッシュ情報を借りる形になり、結果として「単純な静的解析」で十分な精度が出ました。

効果も大きく、Marchenko 氏によれば、スキップ漏れによる誤りを 100 件中 1 件からほぼゼロまで削減できたとのことです。統合コストも小さく、大きな Rails プロジェクトでも全 ISeq のスキャンは 1 秒未満で終わります。テスト実行サイクル中に 1 回だけやればいいので、全体への影響はわずかです。

まとめ:3 軸でカバレッジを定義する

Ruby のテストにとって「テストカバレッジ」とは何か、という問いに対する Marchenko 氏たちの結論は次のとおりです。

  1. テストによって実行された行(行カバレッジ)
  2. テスト中にアロケートされたオブジェクトのクラス(アロケーションプロファイリング)
  3. テストコード(および関連するソースファイル)からアクセスされた定数(バイトコード静的解析)

「行 → アロケーション → 定数」という積み上げが、Ruby の言語特性(クラスロード時の宣言、定数の即値展開、オブジェクト生成中心の設計)と一対一に対応していて、聞いていて非常に納得感のあるセッションでした。

参考資料