SUZURI Rails flaky test

SUZURIで遭遇したflaky testの事例集

SUZURI Rails flaky test

SUZURI Webアプリケーションエンジニアのarumaです。SUZURIにはRSpecによるテストコードが多数ありますが、一時期flaky testが増えてCIが不安定になってしまったことがありました。まとめて調査・対処した際の記録から、いくつかの事例をピックアップしてご紹介します。

なお、記事中のコードは説明のために簡略化したものです。

  1. 事例1: 2026年になると失敗するテスト
  2. 事例2: 深夜0時ちょうどにのみ失敗するテスト
  3. 事例3: 保証のない順序に依存していたテスト
  4. 事例4: 不正なテストデータが作られていたテスト
  5. 事例5: 偶然の一致で失敗するテスト
  6. 番外編: そもそも実行されていなかったテスト
  7. おわりに

事例1: 2026年になると失敗するテスト

これは厳密にはflaky testというより「ある時点から必ず失敗するようになったテスト」ですが、テストの書き方に共通する教訓があるので紹介します。

クレジットカード決済に関するテストのセットアップ内で、カードの有効期限がハードコードされていました。

let(:credit_card) do
  create(
    :credit_card,
    expire_year: 2025,
    expire_month: 12
  )
end

このコードが書かれたのは2020年。5年間は問題なく動いていましたが、2026年を迎えた途端に有効期限切れの無効なカードとなり、テストの検証対象とは無関係な理由でエラーが発生するようになってしまいました。

対処として、有効期限が常に未来の日付となるよう、現在の日付から算出する形に変更しました。

let(:credit_card) do
  create(
    :credit_card,
    expire_year: Time.zone.today.year + 1,
    expire_month: 12
  )
end

似た話として、travel_to で固定した時刻がSQLの now() には反映されないために、テストとDBで時刻がずれていたケースもありました。travel_to はRubyプロセス内の時刻を差し替えるだけなので、DBの now() には効きません。これも月をまたいで初めて顕在化しました。

時間の経過でテストの前提が崩れないようにすることが大切です。

事例2: 深夜0時ちょうどにのみ失敗するテスト

夜間に自動作成されるPRだけなぜか時々CIが落ちる、ということがありました。落ちていたのはセールの適用判定に関するテストで、調査するとテストデータの時刻指定方法が原因でした。

セールには開始時刻と終了時刻があります。factoryのデフォルトでは、開始時刻が「今日の00:00:00」に設定されていました。

factory :time_discount do
  start_time { Time.current.beginning_of_day }
  end_time { 1.hour.from_now }
end

そして、終了済みセールのテストでは、終了時刻のみを過去日時に差し替えていました。

let(:time_discount) do
  create(
    :time_discount,
    end_time: 2.minutes.ago
  )
end

ほとんどの時間帯では問題になりませんが、深夜00:00〜00:02の間にテストが実行されると

  • 開始時刻: Time.current.beginning_of_day → 今日の00:00:00
  • 終了時刻: 2.minutes.ago → 昨日の23:58頃

と、終了時刻が開始時刻より前になり、セットアップ段階でバリデーションエラーが発生する状態になっていました。

対処として、時刻の逆転が起きないよう、開始時刻も明示的に指定しました。

let(:time_discount) do
  create(
    :time_discount,
    start_time: 1.hour.ago,
    end_time: 2.minutes.ago
  )
end

パラメータの一部を上書きする際、他のパラメータとの整合性は見落としがちなポイントです。

事例3: 保証のない順序に依存していたテスト

コレクションを返すAPIのテストで、特定の要素が含まれることを検証する際に .first で先頭要素だけをチェックしていました。

result = api_response.items

expect(result.first).to have_attributes(color: "white")

しかし、このAPIは返却順序を保証しておらず、first で取得される要素が実行ごとに変わりうる状態になっていました。

対処として、「先頭が一致すること」ではなく「目的の要素が含まれること」に修正しました。

result = api_response.items

expect(result).to include(an_object_having_attributes(color: "white"))

順序保証のないコレクションに対して first で検証するのは、flaky testの典型的な原因です。

事例4: 不正なテストデータが作られていたテスト

あるモデルが1対多の関連を持ち、そのうち1件を primary: true で代表データとして扱う構造がありました。代表データは has_one で取得できるようになっています。

has_one :primary_record, -> { where(primary: true) }, class_name: "Record"

factoryのデフォルトが primary: true であり、2件作成すると両方がprimaryで作成されていました。

let(:parent) { create(:parent) }
let!(:record_a) { create(:record, parent: parent) } # primary: true
let!(:record_b) { create(:record, parent: parent) } # primary: true

it "正しいURLが組み立てられること" do
  expect(parent.page_url).to eq "https://example.com/records/#{record_a.id}"
end

その結果、内部で primary_record を参照していた、URLを組み立てるメソッドのテストがflakyになっていました。has_one は該当レコードが複数あってもエラーにはならず、いずれか1件を返すため、record_b が返された場合にテストが落ちていました。

2件目の作成時に primary: false を指定することで解消しました。

テストデータのセットアップでは、実環境では起こりえないようなデータを作ってしまわないよう注意が必要です。

事例5: 偶然の一致で失敗するテスト

SUZURIでは、Tシャツなどの物理商品に加えて、スマホ用壁紙画像などのデジタルコンテンツを販売することもできます。これらを横断的に取得するAPIがあり、そのテストで問題が発生しました。

APIのレスポンスには順序保証がなく、かつ各エントリの属性を個別に検証する必要があったため、IDで目的のレコードを探していました。イメージとしては以下のようなコードです。

let(:product) { create(:product) }
let(:digital_product) { create(:digital_product) }

before do
  register(product)
  register(digital_product)
end

it do
  entries = api_response["entries"]

  product_entry         = entries.find { |entry| entry["id"] == product.id.to_s }
  digital_product_entry = entries.find { |entry| entry["id"] == digital_product.id.to_s }

  # product専用の検証
  verify_product_attributes(product_entry)
  # digital_product専用の検証
  verify_digital_product_attributes(digital_product_entry)
end

productdigital_product は別テーブルであり、IDの採番は独立しています。テストの実行時に両者にたまたま同じIDが振られると、find が誤った方にマッチしてしまい、属性の検証で失敗するという状況でした。

対処として、IDに加えて型情報も含めて取得するようにしました。

product_entry         = entries.find { |entry| entry["type"] == "Product" && entry["id"] == product.id.to_s }
digital_product_entry = entries.find { |entry| entry["type"] == "DigitalProduct" && entry["id"] == digital_product.id.to_s }

IDがユニークである範囲を意識する必要があります。

番外編: そもそも実行されていなかったテスト

flaky test調査の過程で発見した、「そもそも実行されていなかったテスト」の話です。

有効なテストコードが書かれたあるテストファイルの名前が _spec.rb で終わっておらず、RSpecの実行対象に含まれていませんでした。

再発防止として、テストが記述されたRubyファイルが _spec.rb で終わっていることをチェックする仕組みを導入しました。

おわりに

どの事例も、原因がわかってしまえば対処は簡単でした。手間がかかるのは、原因を突き止めるところです。今回、flaky testをまとめて対処できたのは、遭遇するたびにClaude Code等のAIにバックグラウンドで調査を任せていたことが大きいです。従来であれば「再実行したら通ったからいいか」と見過ごしがちだったものも、メインの作業と並行して対処を進められるようになりました。

本記事で紹介した事例は比較的シンプルなものですが、中には複数の要因が重なり、原因の特定に大きく手間取ったケースもありました。その話は明日の記事で詳しく紹介します。