前回の記事で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時間 | 改善率 |
|---|---|---|
| 改善前 | 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 |


コメント