🎉

定数が原因で Rspec が突然失敗するようになってしまった

2023/11/30に公開

はじめに

先日既存プロダクトへの機能追加と一緒に新機能に対する Rspec を追加したところ、その時追加したのとは全く関係ない既存機能の Rspec が失敗するようになってしまいました。
単純な原因ではあったものの原因特定までにそこそこ時間がかかってしまったので解決までの過程を残しておきます。

原因

最初に原因を言うと、タイトルの通り Rspec 内の定数が原因でした。
テストが失敗するようになってしまった Rspec では以下のようにグローバルな定数を定義していたのですが、これと同名の定数を使う別のテストが同一プロセス内に存在し、定数内容がバッティングしてしまったようでした。

require 'rails_helper'

CONST = 'new feature const'

RSpec.describe 'NewFeature' do
  it 'CONST' do
    expect(CONST).to eq('new feature const')
  end
end

突然失敗するようになってしまった原因

プロダクトでは buildkite の parallelism 設定を使って複数プロセスで並行してテストを実行しています。
プロセス内で実行されるテストの組み合わせは knapsack で最適化されているため一部のテストに変更があれば、全体のテストの組み合わせも変わることがあります。
新機能のテストを追加したことで実行の組み合わせが変更になり、たまたま同じ定数名を使っているテスト同士が同じプロセスで実行される組み合わせになってしまったようです。

原因特定までの過程

新機能の開発は develop ブランチから feature ブランチを切って行っていましたが、develop では失敗していない feature ブランチとは無関係のテストが急に失敗してしまったので最初は何も検討がつかず、とりあえず直接原因を探ってみました。

直接原因の特定

実際のテストは定数に設定したパスから json ファイルを読み込みその内容をモックとしてコントローラーに渡し正しく値が処理されるかを検証するもので、一部のテストでリクエスト内容とは全く関係のない結果が出てしまっていました。
同じコントローラーに対してのテストではテストケースに応じて複数パターンのモックファイルが用意されており、その中に失敗したテストと同じ結果になることが期待されるモックファイルがありました。
試しにそのモックファイルのパラメーターを失敗しているテストで使用しているモックファイルの内容に変えてみたところ、今まで失敗していたテストが成功しました。

根本原因の特定

直接的には意図していないモックファイルを参照してしまっていることが原因であるとわかったものの、該当のテストファイルやモックファイル、該当機能のロジックは一切変更していなかったので、なぜ突然テストが失敗してしまうようになったのかはわかりませんでした。
これはローカルで該当のテストファイル単体で実行した場合には発生せず CI で buildkite での実行時のみ発生していました。
buildkite では knapsack を使ってテストの組み合わせは最適化されているため、テストの順番や組み合わせに問題があるのかと思い buildkite のログから今回のテストの組み合わせを確認しました。

[2023-11-21T07:43:56Z] Report specs:
[2023-11-21T07:43:56Z] spec/spec/existing_feature1_spec.rb
[2023-11-21T07:43:56Z]
[2023-11-21T07:43:56Z] Leftover specs:
[2023-11-21T07:43:56Z] spec/new_feature_spec.rb
[2023-11-21T07:43:56Z] spec/spec/existing_feature2_spec.rb
[2023-11-21T07:43:56Z] spec/spec/existing_feature3_spec.rb

また、ログを見てみると以下のようなログも見つかりました。

[2023-11-21T07:44:12Z] /myapp/spec/spec/existing_feature2_spec.rb:3: warning: already initialized constant PATH
[2023-11-21T07:44:12Z] /myapp/spec/new_feature_spec.rb:3: warning: previous definition of PATH was here

existing_feature2_spec の方は今まで確認できていなかったのでファイルを見てみると、確かに失敗しているテストと同じ定数をグローバルで宣言していました。

グローバル定数と knapsack の実行順について

トップレベルで定数を定義すると Object に属する定数として扱われ、すべての場所から呼び出せるようになります。
そのため、同一プロセス内でテストを実行していた場合、他のテストファイルで定義したグローバル定数も参照可能となります。
当然一つの定数には一つの値しかセットできないので、同時実行のテスト間で同じ定数を宣言していた場合片方の定数定義のみが残ります。
knapsack によりまとめられたテストでは、テストの実行前に一度実行対象のテストファイルをロードするようになっており、定数定義もそのタイミングで実行されます。
ロードの順番は実行順と同じなので、テストを実行するタイミングでは最後にロードされたファイルの定数定義が残ることになります。

対応

今回はおとなしく定数定義していた値を let で定義するようにして対応しました。

require 'rails_helper'

RSpec.describe 'NewFeature' do
  let (:const) { 'new feature const' }
  it 'CONST' do
    expect(const).to eq('new feature const')
  end
end

Discussion