为什么你的 Rails fixture 访问器会(而且只是偶尔)返回数组

broadcasts(:announcement) 这样的 Rails fixture 访问器本应返回一条记录。但有时它返回的却是一个空 Array,而测试会在远离真正原因的地方失败。它的表现还不稳定:在本地某次运行通过,下一次失败,在 CI 中则每次都失败。

根本原因是一个 Rails 7.1 才使其成为可能、并由 turbo-rails 通过 Action Cable 触发的方法名冲突。本文将讲解症状、如何诊断,以及四种修复方法。事情的起点是这样一个失败的测试:

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

它指向的那一行平平无奇:

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

令人困惑的是:紧挨着它上面的那一行对另一个 fixture set 使用了完全相同的写法,而那一行没有问题。

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

两个调用使用相同的访问器写法,位于同一个 fixtures 目录,在同一个测试中运行,但一个返回记录,另一个返回 Array。而且它并不一致:在本地某些运行通过,另一些失败,在 CI 中则稳定失败。一个只命中某一个 fixture set、而且只是偶尔出现的失败,正是它难以定位的原因。

关于这个项目 顺带一提,这个问题是在做 sendbroadcast.net(一个自托管的邮件平台)时遇到的。如果你好奇这个 Broadcast 模型所属的项目,它就在那里。

说不通的症状

最先怀疑的是 fixture 损坏:损坏的 broadcasts.yml、重复的键、fixture 之间的加载顺序问题。这些都站不住脚。YAML 解析正常,直接加载这个 fixture set 能返回记录,所以问题出在访问器而不是数据。

接下来怀疑的是“不稳定就意味着时序问题”这种想当然。它不是线程也不是时钟的问题。在给定某个进程状态下,这个机制是确定性的。只不过它所依赖的那个进程状态本身在每次运行之间是非确定的。这是最有意思的部分,下面会详细说明。

Rails 如何解析 fixture 访问器

broadcasts(:announcement) 看起来像一个生成出来的方法,在 Rails 7.0 之前它确实是。从 Rails 7.1 起,fixture 访问器改为通过 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 不会运行,fixture 访问器被悄无声息地绕过。

这才是真正的根本原因,值得把它说清楚。在 Rails 7.1 之前,fixtures 会用 define_method 直接在测试类上生成一个真实的访问器。定义在类上的方法优先级高于任何来自 include 模块的方法,所以生成出来的 broadcasts 访问器会胜过 ActionCable::TestHelper#broadcasts,这个 bug 根本不可能发生。Rails 7.1 把这些生成的方法换成了 method_missing(提交 05d80fc24f,“Reduce the memory footprint of fixtures accessors”,因为在大型测试套件中为每个 fixture set 定义访问器开销很大)。这个优化悄悄地把优先级反转了:类上没有了真实方法,来自 include 模块的同名方法就赢了,method_missing 永远到不了。这个冲突是一次性能改进的副作用,这也是没人会想到去查它的很大一部分原因。

追查

那么,是谁定义了一个真实的 broadcasts?grep 一下 Rails 源码:

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

罪魁祸首在此:一个名为 broadcasts、接受单个参数并返回 Array(channels_data[channel] ||= [])的方法。给它传 :announcement,它会返回本次测试中广播到名为 :announcement 的 stream 的消息列表,也就是 []。对 [] 调用 .id 正是引发 undefined method 'id' for an instance of Array 的原因。错误信息终于说得通了。

但这个测试并没有 include 任何 Action Cable 测试辅助方法。那这个方法为什么会出现在作用域里?沿着 include 链往下看:

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

ActionCable::TestHelperbroadcasts 委托给测试用的 pubsub 适配器。所以任何 include 了 ActionCable::TestHelper 的东西,都会得到一个真实的 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.yml 设为 adapter: test,所以 pubsub_adapter 是测试适配器,被委托的 broadcasts 在调用处不会抛异常,而是返回 []。这正是它没有在第一次运行时被抓住、反而潜伏了好几个月的原因。

它为什么不稳定

这一部分值得慢下来细看,因为正是它把一个小 bug 变成了一个要花一周才追查出来的 bug。

再看一眼 turbo-rails 的 initializer。那个 include 被包在 on_load(:action_cable) 里,而这个钩子是由 actioncable/lib/action_cable/server/base.rb:107 处的 ActiveSupport.run_load_hooks(:action_cable, ...) 触发的。它在 ActionCable::Server::Base 常量被加载时运行,实际上就是 ActionCable.server 第一次被引用时(一次 Turbo 广播,或一个 Action Cable 测试辅助方法)。在启动时 require action_cable/engine 并不会触发它。所以在 ActionCable::Server::Base 被加载之前,真实的 broadcasts 方法并不存在于你的测试继承链中。在此之前,method_missing 正常工作,你的 fixture 访问器返回正确的记录。

ActionCable::Server::Base 是否在某个测试运行之前被加载,取决于 eager loading:

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

在 CI 中 eager_load 为 true,所以 Zeitwerk 在启动时就 eager load server/base.rb。钩子在任何测试运行之前触发,broadcasts 在所有地方都被遮蔽,测试 一致地 失败。这一部分从来都不是不稳定的。

在本地 eager_load 为 false,所以 ActionCable::Server::Base 在第一次被引用时才延迟加载,也就是某个测试执行到构建 cable server 的代码路径时。再把这一点和两个默认设置组合起来:

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

再加上 Minitest 的随机测试顺序。测试每次运行都以不同的分配、不同的顺序,被切分到 fork 出来的 worker 进程中。在某次运行里,处理这个测试的 worker(因为同一 worker 里某个更早的测试)先加载了 Action Cable,遮蔽方法已经就位,测试失败。在另一次运行里,这个测试在它所属 worker 中任何加载 Action Cable 的东西之前就执行了,method_missing 仍然有效,测试通过。

于是,同一个测试、代码毫无改动,却会因为随机种子以及本次运行中并行 worker 如何切分而时绿时红。它看起来就是一个普通的不稳定测试,尽管真正的原因是一个只在继承链中条件性存在的方法。

修复

把四种放在一起讲;它们各自针对不同的层面。

1. 使用有文档记录的 fixture() 访问器(推荐)

Rails 早就预料到了这一点。对于名称会与其他方法冲突的 fixture set,有一个 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)

这就是该选用的修复。它明确指出你指的是哪个 fixture set,而且因为它是一个真实的 public 方法,不受这个冲突影响。

2. 跳过访问器,直接查询

如果你压根不想依赖 fixture 访问器这层接口:

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

健壮且直白,代价是把测试耦合到属性值而不是 fixture 标签,以及多一次查询。

3. 重命名 fixture set,使其不会冲突

test/fixtures/broadcasts.yml 改成一个不会冲突的名字,再把它指回模型:

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

announcement:
  subject: "Announcement"

这样 email_broadcasts(:announcement) 就会干净地走 method_missing,因为没有任何东西定义 email_broadcasts。这为团队中所有人移除了这个陷阱,但改动影响面大,而且与表名约定相悖。

4. 让失败变得确定

即便已经应用了上面某一种修复,底层的不稳定性也值得消除,好让下一次冲突每次都以相同方式失败,而不是断断续续。这种不稳定来自一个只有在 Action Cable 加载之后才存在的方法,所以在测试启动时强制 eager load:

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

这并不修复冲突本身。它做的是把断续的失败变成一致的失败,这在实践中是实打实的改进:每次都失败的测试会被调查并修复,而五次只失败一次的测试通常会被一直重试到通过,然后被遗忘。

一般性的教训

这里真正的问题并不是 broadcasts 这个名字。这个陷阱是结构性的:任何名称与测试用例可达的 public 实例方法相同的 fixture set 都会被悄悄绕过,因为 fixture 访问器存在于 method_missing 中,只要有真实方法就一定是真实方法赢。

冲突如何表现,完全取决于冲突相对的那个方法返回什么。名为 name 的 fixture set 会解析到测试自身的 name 并返回一个 String;名为 hash 的会返回一个 Integer。其中一些会很快炸开,另一些则悄悄返回错误的东西,很久之后才失败。broadcasts 这种情况更多是恼人而非危险:冲突相对的方法返回 [],所以调用处什么都不会坏,错误只会在之后,作为一个与 fixture 毫无关联的无关行上的 NoMethodError 浮现出来。

在这个案例里,模型只是被命名为 Broadcast。Rails 把它复数化成一个 broadcasts 表和一个 test/fixtures/broadcasts.yml fixture set,所以访问器就是 broadcasts,恰好是 ActionCable::TestHelper 所定义的那个名字。这个名字毫无异常之处,而这正是问题的全部。我每隔一段时间就会撞上某种版本的它:一个简短、自然的模型名,恰好与 Rails 或其生态中某个 gem 已经定义的方法重名。测试会立刻开始出怪问题,但仍要花不少时间才能查出来,因为名称冲突很少是你第一个会去查的东西。在给模型起一个很通用的名字之前,值得再想一想。

一个快速自查你自己套件的方法:用 Rails 同样的方式推导出每个 fixture set 名,再和测试用例实际响应的方法比对。要在应用 eager load 之后、并且 ActionCable::Server::Base 已加载之后运行,否则 turbo-rails 的 helper 还没被混入,你真正关心的那个冲突就不会出现。注意带命名空间的 fixture 会把斜杠转成下划线(admin/users.yml 变成 admin_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) 来读取的 fixture set。