Railsのフィクスチャアクセサが(しかも時々だけ)配列を返す理由

broadcasts(:announcement) のようなRailsのフィクスチャアクセサは、本来レコードを返すはずです。ところが時々、代わりに空の Array を返し、テストは真の原因から遠く離れた場所で失敗します。しかも挙動が不安定で、ローカルではある実行で成功し、次の実行で失敗し、CIでは毎回失敗します。

根本原因は、Rails 7.1が可能にしてしまい、turbo-railsがAction Cable経由で引き起こすメソッド名の衝突です。この記事では、その症状、診断方法、そして4つの修正方法を扱います。発端はこんなふうに失敗するテストでした:

NoMethodError: undefined method 'id' for an instance of Array

指していた行は、何の変哲もないものでした:

broadcast = broadcasts(:announcement)
assert_equal broadcast.id, recipient.broadcast_id

不可解だったのは、すぐ上の行がまったく同じパターンを別のフィクスチャセットに対して使っていて、そちらは問題なかったことです。

channel  = broadcast_channels(:main_channel)  # works, every time
broadcast = broadcasts(:announcement)         # Array, sometimes

どちらの呼び出しも同一のアクセサパターンを使い、同じフィクスチャディレクトリにあり、同じテストの中で実行されているのに、一方はレコードを返し、もう一方は Array を返します。しかも一貫しておらず、ローカルではある実行で成功し、別の実行で失敗し、CIでは確実に失敗しました。あるフィクスチャセットだけで起こり、しかも時々しか起こらない失敗、これが原因の特定を難しくしました。

このプロジェクトについて ちなみに、これは sendbroadcast.net というセルフホスト型メールプラットフォームを作っている中で出てきたものです。この Broadcast モデルが属するプロジェクトが気になる方は、そちらをご覧ください。

意味の通らなかった症状

最初に疑うのはフィクスチャの破損です: 壊れた broadcasts.yml、キーの重複、フィクスチャ間のロード順の問題。どれも当てはまりませんでした。YAMLは正しくパースされ、フィクスチャセットを直接ロードするとレコードが返ったので、問題はデータではなくアクセサ側にありました。

次に疑うのは「不安定=タイミングの問題」という思い込みです。スレッドでもクロックでもありませんでした。このメカニズムは、特定のプロセス状態が与えられれば決定論的に動きます。たまたま、その依存先のプロセス状態自体が実行ごとに非決定的だっただけです。これが一番面白いところなので、後で詳しく説明します。

Railsはフィクスチャアクセサをどう解決するか

broadcasts(:announcement) は生成されたメソッドのように見えますし、Rails 7.0までは実際にそうでした。Rails 7.1以降、フィクスチャアクセサは代わりに method_missing 経由で解決されます:

# activerecord/lib/active_record/test_fixtures.rb:301
def method_missing(method, ...)
  if fixture_sets.key?(method.name)
    active_record_fixture(method, ...)
  else
    super
  end
end

肝心な点はこうです: method_missingその名前の実メソッドが継承チェーンのどこにも存在しないとき にしか発火しません。テストクラスに生成された broadcasts メソッドは存在しません。他に broadcasts を定義するものがない限り、Rubyはメソッドを見つけられず、method_missing に落ち、fixture_sets の中に broadcasts を見つけ、レコードを返します。

何かが実体のある broadcasts インスタンスメソッドを定義した瞬間、Rubyはそちらを先に見つけ、method_missing は実行されず、フィクスチャアクセサは静かに迂回されます。

これが本当の根本原因であり、正確に押さえておく価値があります。Rails 7.1以前、fixturesdefine_method で実体のあるアクセサをテストクラスに直接生成していました。クラスに定義されたメソッドはincludeされたモジュール由来のものより優先されるため、生成された broadcasts アクセサが ActionCable::TestHelper#broadcasts に勝っており、このバグは起こりようがありませんでした。Rails 7.1はそれらの生成メソッドを method_missing に置き換えました(コミット 05d80fc24f、“Reduce the memory footprint of fixtures accessors”、大規模なテストスイートでフィクスチャセットごとにアクセサを定義するのはコストが高いため)。この最適化が優先順位を静かに反転させました。クラスに実メソッドがなくなったことで、includeされたモジュール由来の同名メソッドが勝つようになり、method_missing には到達しなくなったのです。この衝突はパフォーマンス改善の副作用であり、それが誰もこの原因を疑わない理由の大きな部分です。

追跡

では、実体のある broadcasts を定義しているのは何か。Railsのソースをgrepします:

grep -rn "def broadcasts\b" "$(bundle info actioncable --path)/lib"
# actioncable/lib/action_cable/subscription_adapter/test.rb:23
def broadcasts(channel)
  channels_data[channel] ||= []
end

これが犯人です: 引数を1つ取り、Array(channels_data[channel] ||= [])を返す broadcasts というメソッド。これに :announcement を渡すと、このテスト中に :announcement というストリームへブロードキャストされたメッセージの一覧、つまり [] が返ります。その [] に対して .id を呼ぶことが undefined method 'id' for an instance of Array を発生させていたのです。ようやくエラーメッセージの意味が通りました。

しかしこのテストはAction Cableのテストヘルパーを一切includeしていません。なぜそのメソッドがスコープに入っているのか。includeチェーンを辿ります:

# actioncable/lib/action_cable/test_helper.rb:146
delegate :broadcasts, :clear_messages, to: :pubsub_adapter

ActionCable::TestHelperbroadcasts をテスト用pubsubアダプタに委譲します。つまり ActionCable::TestHelper をincludeするものはすべて、実体のある broadcasts インスタンスメソッドを得ます。では、誰がそれをincludeしているのか。

# turbo-rails: lib/turbo/broadcastable/test_helper.rb:7
module Turbo
  module Broadcastable
    module TestHelper
      include ActionCable::TestHelper

そしてturbo-railsはそれをすべてのテストケースに混ぜ込みます:

# turbo-rails: lib/turbo/engine.rb:103
ActiveSupport.on_load(:action_cable) do
  ActiveSupport.on_load(:active_support_test_case) do
    if defined?(ActiveJob)
      require "turbo/broadcastable/test_helper"
      include Turbo::Broadcastable::TestHelper
    end
  end
end

あなたが一行も書いていない、その全チェーン:

turbo-rails (in your Gemfile)
  -> on_load(:action_cable) + on_load(:active_support_test_case)
  -> include Turbo::Broadcastable::TestHelper
  -> include ActionCable::TestHelper
  -> delegate :broadcasts to :pubsub_adapter
  -> ActionCable::SubscriptionAdapter::Test#broadcasts -> []

テスト環境では config/cable.ymladapter: test に設定されているため、pubsub_adapter はテストアダプタになり、委譲された broadcasts は呼び出し箇所で例外を投げずに [] を返します。だからこそ、最初に実行されたときに捕まらず、何ヶ月も気づかれなかったのです。

なぜ不安定だったのか

ここは時間をかける価値があります。小さなバグを、追跡に1週間かかるバグへと変えたのがここだからです。

turbo-railsのイニシャライザをもう一度見てください。includeon_load(:action_cable) で包まれており、このフックは actioncable/lib/action_cable/server/base.rb:107ActiveSupport.run_load_hooks(:action_cable, ...) によって発火します。これは ActionCable::Server::Base 定数がロードされたときに走り、実際には ActionCable.server が初めて参照されたとき(Turboのブロードキャスト、またはAction Cableのテストヘルパー)を意味します。起動時に action_cable/engine をrequireしても発火しません。つまり、ActionCable::Server::Base がロードされるまで、実体のある broadcasts メソッドはテストの継承チェーンに存在しません。それまでは method_missing が機能し、フィクスチャアクセサは正しいレコードを返します。

ActionCable::Server::Base が特定のテストの実行前にロードされるかどうかは、eager loadingに依存します:

# config/environments/test.rb
config.eager_load = ENV['CI'].present?

CIでは eager_load がtrueなので、Zeitwerkが起動時に server/base.rb をeager loadします。フックがどのテストよりも前に発火し、broadcasts はどこでも覆い隠され、テストは 一貫して 失敗します。その部分は決して不安定ではありませんでした。

ローカルでは eager_load がfalseなので、ActionCable::Server::Base は最初に参照されたとき、つまりどこかのテストがケーブルサーバーを構築するコードパスを実行したときに初めて遅延ロードされます。これを2つのデフォルト設定と組み合わせます:

# test/test_helper.rb
parallelize(workers: :number_of_processors)

さらにMinitestのランダムなテスト順序が加わります。テストは実行ごとに異なる分配で、異なる順序で、フォークされたワーカープロセスに振り分けられます。このテストを処理するワーカーが(同じワーカー内の先行テストが原因で)先にAction Cableをロードした実行では、覆い隠すメソッドが既に存在しテストは失敗します。このテストが、そのワーカー内でAction Cableをロードする何かより前に実行された実行では、method_missing がまだ機能しテストは成功します。

つまり、コードを一切変えていない同じテストが、ランダムシードと、その実行で並列ワーカーがどう分割されたかによって、成功したり失敗したりするわけです。見た目はごく普通の不安定なテストですが、本当の原因は継承チェーンに条件付きでしか存在しないメソッドなのです。

修正

4つすべてをまとめて示します。それぞれ異なる層に対処します。

1. ドキュメント化された fixture() アクセサを使う(推奨)

Railsはまさにこれを見越していました。他のメソッドと名前が衝突するフィクスチャセット用のpublicアクセサがあります:

# activerecord/lib/active_record/test_fixtures.rb:114
# Generic fixture accessor for fixture names that may conflict with other methods.
#
#   assert_equal "Ruby on Rails", web_sites(:rubyonrails).name
#   assert_equal "Ruby on Rails", fixture(:web_sites, :rubyonrails).name
def fixture(fixture_set_name, *fixture_names)
  active_record_fixture(fixture_set_name, *fixture_names)
end

このファイルの private キーワードのすぐ上に定義されているので、publicであり、実体のあるメソッドであり、method_missing を経由しません。この衝突の影響を受けません:

broadcast = fixture(:broadcasts, :announcement)

これが選ぶべき修正です。どのフィクスチャセットを指しているかを明示でき、実体のあるpublicメソッドであるため、この衝突の影響を受けません。

2. アクセサを使わず、直接クエリする

フィクスチャアクセサという表面に一切依存したくない場合:

broadcast = Broadcast.find_by(subject: "Announcement")

頑健で分かりやすい一方、テストをフィクスチャのラベルではなく属性値に結びつけ、クエリが1回増えます。

3. 衝突しないようにフィクスチャセットの名前を変える

test/fixtures/broadcasts.yml を衝突しない名前に移し、モデルに紐づけ直します:

# test/fixtures/email_broadcasts.yml
_fixture:
  model_class: Broadcast

announcement:
  subject: "Announcement"

すると email_broadcasts(:announcement) は、email_broadcasts を定義するものが何もないため method_missing をすっきり通ります。これでチーム全員にとっての罠は取り除けますが、変更の影響範囲が大きく、テーブル名の規約に逆らうことになります。

4. 失敗を決定論的にする

上記のいずれかを適用していても、根底にある不安定さは無効化しておく価値があります。次の衝突が、断続的にではなく毎回同じ形で失敗するようにするためです。不安定さはAction Cableのロード後にしか存在しないメソッドから来ているので、テストの起動時にeager loadを強制します:

# test/test_helper.rb
Rails.application.eager_load!

これは衝突そのものを直すわけではありません。やっているのは、断続的な失敗を一貫した失敗に変えることであり、これは実務上はっきりとした改善です。毎回失敗するテストは調査され修正されますが、5回に1回だけ失敗するテストはたいてい成功するまでリトライされ、そして忘れられます。

一般的な教訓

ここでの本当の問題は broadcasts という名前ではありません。罠は構造的なものです: テストケースから到達可能なpublicインスタンスメソッドと同名のフィクスチャセットはすべて、静かに迂回されます。フィクスチャアクセサが method_missing の中に存在し、実メソッドがあれば必ずそちらが勝つからです。

衝突がどう現れるかは、衝突相手のメソッドが何を返すかに完全に依存します。name という名前のフィクスチャセットはテスト自身の name に解決され String を返します。hash という名前なら Integer を返します。それらの一部はすぐに派手に落ちますが、別のものは静かに誤った値を返し、ずっと後で失敗します。broadcasts のケースは危険というより厄介です: 衝突相手のメソッドが [] を返すため、呼び出し箇所では何も壊れず、エラーは後になって、フィクスチャを何も指し示さない無関係な行で NoMethodError として表面化します。

今回のケースでは、モデルの名前が単に Broadcast でした。Railsはこれを broadcasts テーブルと test/fixtures/broadcasts.yml フィクスチャセットに複数形化するので、アクセサは broadcasts、まさに ActionCable::TestHelper が定義する名前になりました。その名前に何も変わったところはなく、それこそが問題のすべてです。私はこの手のことに時々出くわします: 短く自然なモデル名が、たまたまRailsやそのエコシステムのgemが既に定義しているメソッドと一致するのです。テストはすぐに変な挙動を始めますが、名前の衝突を最初に疑うことはまずないため、追跡には時間がかかります。モデルにありふれた名前を付ける前に、もう一度考える価値があります。

自分のスイートを手早く監査する方法: Railsと同じやり方で各フィクスチャセット名を導出し、テストケースが実際に応答するメソッドと突き合わせます。アプリがeager loadされた後、かつ ActionCable::Server::Base がロードされた後に実行してください。さもないとturbo-railsのヘルパーがまだ混ぜ込まれておらず、問題にしたい衝突が現れません。名前空間付きフィクスチャはスラッシュがアンダースコアに変換され(admin/users.ymladmin_users になる)、privateメソッドもpublicメソッドと同じくらい確実にアクセサを覆い隠す点に注意してください:

# bin/rails runner -e test
Rails.application.eager_load!
ActionCable.server # fires the :action_cable load hook so turbo-rails mixes its helper in

fixture_root = "test/fixtures"
fixture_sets = Dir["#{fixture_root}/**/*.yml"].map do |f|
  f.delete_prefix("#{fixture_root}/").delete_suffix(".yml").tr("/", "_")
end

collisions = fixture_sets.select do |n|
  ActiveSupport::TestCase.method_defined?(n) ||
    ActiveSupport::TestCase.private_method_defined?(n)
end
puts collisions

出力されたものはすべて、fixture(:name, :label) 経由でのみ読むべきフィクスチャセットです。