速くしたCIがflaky testで台無しだった — 4件修正+rubocop並列化で66%短縮まで到達した話

前回の記事でRails CIを8分→3分半に短縮しました。55%の削減です。

しかし速くなったCIで、今まで見えなかった問題が浮き彫りになりました。flaky testです。3分半のCIが失敗→リトライで7分。改善前と変わりません。

この記事では、前回の続きとして「55%短縮の次にやったこと」を書きます。flaky test 4件の根本原因を修正し、さらにrubocop並列化やMatrix再分割を加えて、最終的に66%短縮(17分→5分42秒)まで到達した全過程です。

対象は10年以上稼働しているRailsアプリです。classic autoloaderや古いrubocopが出てきますが、まずCIを安定・高速化してからモダナイズする戦略で進めています。

やったことの全体像

施策 効果
flaky test 4件修正 リトライ発生率ゼロへ(体感7分→3分半)
before_allファクトリ共有 + http分割 ファクトリ87%削減 + 最遅ジョブ7分→5分半
rubocop最新化 + --parallel + checkout@v4 rubocop 5分→1分19秒

flaky testの4大パターン

今回修正した4件は、以下のパターンに分類できます。これから1件ずつ詳細を解説します。

パターン 原因 対策
グローバル状態の汚染 Timecop.freeze のリセット漏れ after で確実にリセット
スタブの副作用 ENV[] 全体がスタブされる and_call_original を併用
autoloaderの読み込み順序 定数がテスト順序で未定義になる :: プレフィックスで明示
期待値の陳腐化 機能変更で作成レコード数が変化 機能変更時に期待値を確認

4件に共通するのは、「特定のテスト実行順序でしか発生しない」という点です。--seed を固定すれば再現できますが、CIのログから失敗時のseedを拾って手元で再現する手順を踏まないと、原因にたどり着けません。

Flaky Test 1: Timecop.freezeのリセット漏れ

症状

時刻計算を扱うspecが先に実行されると、後続のまったく関係ないテストが失敗する。失敗するテストは毎回異なり、時刻に依存するアサーションで落ちる。

原因

Timecop.freeze でテスト内の時刻を固定しているが、after ブロックに Timecop.return がなかった。

# Before: リセット漏れ
describe DateRangeCalculator do
  before do
    Timecop.freeze(Time.zone.parse('2026-01-15 10:00:00'))
  end

  # after { Timecop.return } がない
end

ランダムseedでこのspecが先に実行されると、後続テストはすべて 2026-01-15 10:00:00 の時刻で動きます。

修正

# After: afterブロックでリセット
after { Timecop.return }

`Timecop.freeze` を使う場合、ブロック形式 `Timecop.freeze(time) { … }` を使えばリセット漏れを防げます。

Flaky Test 2: ENV[]スタブの副作用

症状

外部連携系のspecの後に実行されるテストで、以下のエラーが出る。テスト対象と無関係なBundler関連のエラーです。

ENV received :[] with unexpected arguments
  expected: ("EXTERNAL_API_KEY"), got: ("BUNDLE_GEMFILE")

原因

# Before: ENV[]全体をスタブしてしまう
allow(ENV).to receive(:[]).with('EXTERNAL_API_KEY').and_return('secret')

RSpecの receive(:[])ENV[] メソッド全体をスタブします。.with('EXTERNAL_API_KEY') はメッセージの期待値であり、他のキーへのアクセスを許可するわけではありません。

テスト実行順序によっては、このスタブが有効な状態で DatabaseCleaner.clean が走ります。内部で ENV['BUNDLE_GEMFILE'] を参照し、クラッシュします。

修正

# After: and_call_originalで元の動作を維持しつつ、特定キーだけスタブ
allow(ENV).to receive(:[]).and_call_original
allow(ENV).to receive(:[]).with('EXTERNAL_API_KEY').and_return('secret')

`allow(ENV).to receive(:[]).with(…)` は「そのキーだけスタブしている」ように見えます。しかし実際には `[]` メソッド全体がスタブされます。`ENV` 等のグローバルオブジェクトには `and_call_original` を併用してください。

Flaky Test 3: Rails autoloaderの定数解決順序

症状

コントローラspecで NameError: uninitialized constant Api::V1::RecordsController::ExternalApi が発生する。ただし毎回ではなく、特定のテスト順序でのみ失敗する。

原因

外部APIクライアントがコンパクト形式で定義されていました。

# lib/external_api/client.rb
class ExternalApi::Client  # ExternalApiモジュールが未定義だとNameError
  def initialize(resource_id, name = '')
    # ...
  end
end

ExternalApi モジュールの定義は別ファイルにしかありません。テスト順序でそのファイルが先にautoloadされていれば問題ありませんが、そうでなければ未定義のまま参照に失敗します。

さらに、コントローラ内で :: プレフィックスなしの ExternalApi::Client を参照していました。classic autoloaderがコントローラの名前空間内で定数を解決しようとして失敗するケースがありました。

修正

# Before
client = ExternalApi::Client.new(params[:resource_id], params[:id])

# After: トップレベル参照を明示
client = ::ExternalApi::Client.new(params[:resource_id], params[:id])

classic autoloaderでは、ネストした名前空間内からの定数参照で予期しない挙動を示すことがあります。外部ライブラリのクラスをコントローラ内で使う場合は `::` プレフィックスをつけると安全です。Rails 6以降のZeitwerk autoloaderではこの問題は発生しません。

Flaky Test 4: テストデータのcount不整合

症状

ユーザー登録系のspecで change(Model, :count).by(1) のアサーションが失敗する。ただし毎回ではなく、特定のテスト順序で他のspecが先にDBをセットアップしている場合にだけ発生する。

原因

機能変更で関連レコードが追加され、登録時に2レコード(本体 + 関連レコード)が作成されるようになっていた。しかしspecの期待値が当時のまま残っていた。

# Before: 機能変更前の期待値のまま
expect {
  post registrations_path, params: valid_params
}.to change(Registration, :count).by(1)

ランダムseedによっては他のspecが先に実行されてDB状態が異なり、関連レコードも作成されるパスを通るため by(1)by(2) と不一致で落ちます。

修正

# After: 機能変更を反映した期待値
expect {
  post registrations_path, params: valid_params
}.to change(Registration, :count).by(2)

さらにCI高速化を進める — before_all + http分割

flaky testを修正した後、さらにCI時間を削るための施策を2つ実施しました。

before_allでファクトリ生成を87%削減

前回の記事で、FactoryProfで特定のファクトリが8,706回生成されている問題を発見しました。バルクINSERT化で87%削減しましたが、まだ各テストが同じベースデータを毎回作り直していました。

test-profの before_all を使い、context内でベースデータを共有するように変更しました。

context "有効なリソースの場合" do
  before_all do
    @account = create(:account)
    @resource1 = create(:resource, account: @account, state: :active)
    @resource2 = create(:resource, account: @account, state: :active)
    bulk_create_items([@resource1.id, @resource2.id], @account.id, 300)
  end

  let(:account) { @account }

  it "上限以下であればエラーではない" do
    # before_allのデータをそのまま使う
    expect(LimitValidator.validate(account, target_plan)).to eq({})
  end

  it "上限を超えればエラー" do
    # 差分の1件だけ追加(テスト後にロールバックされる)
    create(:item, resource: @resource1, account: account)
    expect(LimitValidator.validate(account, target_plan)).to have_key(:over_limit)
  end
end

before_all はcontext内で一度だけデータを作成し、各テストはSAVEPOINTで巻き戻されます。テストごとに追加した差分データだけがロールバックされ、ベースデータは維持されます。

  • Before: テストごとに1,200レコード生成 × 12テスト = ~9,920レコード
  • After: before_allで1,200レコード × 2回 + 差分のみ = ~1,250レコード
  • 87%削減

before_all導入で踏んだ落とし穴: IDの切り詰め

before_all 導入後、Mysql2::Error: Duplicate entry が発生するようになりました。

原因は、テストデータのID生成に SecureRandom.hex(5) を使っていたことです。"TST" + hex(5) + 連番 の全体で14〜16文字になりますが、カラムは varchar(10) でした。MySQLが黙って10文字に切り詰め、末尾の連番部分が消えてコリジョンが発生します。

元のコードではテストごとのトランザクションロールバックで顕在化しませんでした。before_all だと1,200レコードが同一トランザクション内で共存するため、切り詰め後のIDが衝突しました。

# Before: 14〜16文字のID → varchar(10)で切り詰め → コリジョン
item_id: "TST#{SecureRandom.hex(5)}#{i}"

# After: ちょうど10文字の連番
@_bulk_seq ||= 0
@_bulk_seq += 1
item_id: "TST%07d" % @_bulk_seq

テストデータのIDに `SecureRandom` を使う場合、カラムの最大長を確認してください。MySQLはstrict modeでなければ黙って切り詰めます。

httpジョブの分割

前回の記事の時点で、httpジョブ(controllers + routing + requests + mailers)が7分超えで全体のボトルネックでした。これを2つに分割しました。

# Before: 1ジョブ(1,062 examples, 7分超)
- group: http
  spec_dirs: "spec/controllers spec/routing spec/requests spec/mailers"

# After: 2ジョブに分割
- group: http-controllers
  spec_dirs: "spec/controllers spec/routing"  # 651 examples
- group: http-mailers
  spec_dirs: "spec/requests spec/mailers"     # 411 examples

rubocopアップグレード + –parallel

--parallel 未対応の古いバージョンのrubocopを使っていました。--parallel オプションが導入されたバージョンまでアップグレードが必要でした。

依存衝突の解消

単純にバージョンを上げると unicode-display_width のバージョン制約で衝突します。

長年更新が止まっていた hirb-unicode が古い unicode-display_width に依存しており、新しいrubocopが要求するバージョンと噛み合いませんでした。フォーク版の hirb-unicode-steakknife が新しい unicode-display_width に対応していたため、差し替えで解決しました。

# Gemfile
gem 'hirb-unicode-steakknife'  # hirb-unicode から差し替え
gem 'rubocop'                  # --parallel対応バージョンへアップグレード

hirb-unicode はpryコンソールの日本語テーブル表示用で、本番コードには含まれません。

.rubocop_todo.yml の再生成

大幅なバージョンアップで数百のcopが追加されるため、既存コードで大量のoffenseが検出されます。--auto-gen-config.rubocop_todo.yml を再生成し、既存の違反はすべて除外しました。新規コードから徐々に対応していく方針です。

bundle exec rubocop --auto-gen-config --exclude-limit 99999
# .rubocop.yml
AllCops:
  NewCops: disable  # 新しいcopはデフォルト無効
  Exclude:
    - 'db/**/*'
    - 'vendor/**/*'

結果

rubocop単体で 5分→1分19秒 に短縮されました。

最終結果

CIジョブ実行時間の比較
段階 CI時間 改善率
改善前 16m57s
前回の記事(インフラ + アプリ最適化) 8m08s -52%
今回(flaky修正 + rubocop並列化) 5m42s -66%

前回の記事と合わせて、6本のPRで段階的に改善しました。

:::details 全6本のPR(施策一覧)

# 施策
1 テスト並列化(Matrix 4分割)
2 MySQLチューニング + ランナー最適化
3 facades分離 + test-prof導入
4 Timecop漏れのflaky修正
5 before_allファクトリ共有 + http分割 + flaky修正3件
6 rubocopアップグレード + --parallel + checkout@v4

CI高速化とflaky test修正はセットで取り組む

前回の記事で「flaky testの修正はCI時間に直接効かないように見えるが、体感に上乗せされる」と書きました。今回それを実感しました。

3分半のCIでも、flaky testで2回リトライすれば7分。改善前より遅い体感になります。逆にflaky testがゼロなら、CIの数字がそのまま開発のテンポになります。

速いCIの恩恵を受けるには、flaky testを放置しないことが前提です。

flaky testの発見と再現手順

CIが失敗したら、まずログからランダムseedを探します。

# CIログから seed を探す
# 出力例: Randomized with seed 12345

# 同じseedでローカル再現
bundle exec rspec --seed 12345

# 特定のspecだけ再現
bundle exec rspec spec/models/date_range_calculator_spec.rb --seed 12345

seedを固定して再現できたら、--bisect で原因specを絞り込みます。

bundle exec rspec --seed 12345 --bisect

再現できない場合は、CI環境とローカルの差異(タイムゾーン、DBの状態、並列数)を疑います。

チェックリスト

自分のプロジェクトで確認すべきポイントをまとめました。

flaky test

  • [ ] Timecop.freeze / travel_to に対応する Timecop.return / ブロック終了があるか
  • [ ] allow(ENV).to receive(:[]) のように、グローバルオブジェクトのメソッド全体をスタブしていないか
  • [ ] コントローラ内で外部ライブラリのクラスを :: プレフィックスなしで参照していないか
  • [ ] change(...).by(N) の期待値が、最新の機能仕様と一致しているか
  • [ ] テストデータのIDがカラムの最大長を超えていないか

さらなるCI高速化

  • [ ] test-profの before_all でテスト間のデータ共有ができるか
  • [ ] 最遅ジョブが突出していたらMatrix分割で分散できるか
  • [ ] rubocop --parallel が使えるバージョンか
  • [ ] actions/checkout が最新バージョンか

コメント

タイトルとURLをコピーしました