Why Your Rails Fixture Accessor Returns an Array (and Only Sometimes)
A Rails fixture accessor like broadcasts(:announcement) should return a record. Sometimes it returns an empty Array instead, and the test fails later, far from the real cause. It is also flaky: it can pass locally on one run, fail on the next, and fail every time in CI.
The root cause is a method-name collision that Rails 7.1 made possible and that turbo-rails triggers through Action Cable. This post covers the symptom, how to diagnose it, and four ways to fix it. It started with a test failing like this:
NoMethodError: undefined method 'id' for an instance of Array
The line it pointed at was unremarkable:
broadcast = broadcasts(:announcement)
assert_equal broadcast.id, recipient.broadcast_id
The confusing part: the line directly above it used the exact same pattern against a different fixture set, and that one was fine.
channel = broadcast_channels(:main_channel) # works, every time
broadcast = broadcasts(:announcement) # Array, sometimes
Both calls use the identical accessor pattern, live in the same fixtures directory, and run inside the same test, yet one hands back the record and the other hands back an Array. It was not consistent either: it passed locally on some runs, failed on others, and failed reliably in CI. A failure that hits one fixture set but not another, and only some of the time, is what made this hard to pin down.
Broadcast model belongs to, that is where it lives.
The symptom that made no sense
The first instinct is fixture corruption: a bad broadcasts.yml, a duplicate key, a load-order issue between fixtures. None of that held up. The YAML parsed fine, and loading the fixture set directly returned the record, so the problem was in the accessor rather than the data.
The second instinct is that flaky means timing: a thread or a clock. It was neither. The mechanism is deterministic given a particular process state. It just happens that the process state it depends on is itself nondeterministic between runs. More on that below, because it is the most interesting part.
How Rails resolves a fixture accessor
broadcasts(:announcement) looks like a generated method, and until Rails 7.0 it was one. Since Rails 7.1, fixture accessors are resolved through method_missing instead:
# 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
The key point: method_missing only fires when no real method of that name exists anywhere in the ancestor chain. There is no generated broadcasts method sitting on the test class. As long as nothing else defines broadcasts, Ruby fails to find a method, falls through to method_missing, sees broadcasts in fixture_sets, and returns the record.
The moment something defines a real broadcasts instance method, Ruby finds it first, method_missing never runs, and the fixture accessor is silently bypassed.
This is the actual root cause, and it is worth being precise about it. Before Rails 7.1, fixtures generated a real accessor with define_method directly on the test class. A method defined on the class outranks anything from an included module, so the generated broadcasts accessor would have won over ActionCable::TestHelper#broadcasts, and this bug could not have happened at all. Rails 7.1 replaced those generated methods with method_missing (commit 05d80fc24f, “Reduce the memory footprint of fixtures accessors”, because defining an accessor per fixture set across a large suite is expensive). That optimization quietly inverted the precedence: with no real method on the class, a same-named method from an included module now wins, and method_missing is never reached. The collision is a side effect of a performance improvement, which is a large part of why nobody thinks to look for it.
The hunt
So: what defines a real broadcasts? Grep the Rails source:
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
There is the culprit: a method named broadcasts that takes a single argument and returns an Array (channels_data[channel] ||= []). Pass it :announcement and it hands back the list of messages broadcast to a stream called :announcement during this test, which is []. Calling .id on [] is what raises undefined method 'id' for an instance of Array. The error message finally makes sense.
But the test does not include any Action Cable testing helpers. Why is that method in scope at all? Follow the include chain:
# actioncable/lib/action_cable/test_helper.rb:146
delegate :broadcasts, :clear_messages, to: :pubsub_adapter
ActionCable::TestHelper delegates broadcasts to the test pubsub adapter. So anything that includes ActionCable::TestHelper gets a real broadcasts instance method. Who includes it?
# turbo-rails: lib/turbo/broadcastable/test_helper.rb:7
module Turbo
module Broadcastable
module TestHelper
include ActionCable::TestHelper
And turbo-rails mixes that into every test case:
# 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
The full chain, none of which you wrote:
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 -> []
With config/cable.yml set to adapter: test in the test environment, pubsub_adapter is the test adapter, so the delegated broadcasts returns [] rather than raising at the call site. That is exactly why it went unnoticed for months instead of getting caught the first time it ran.
Why it was flaky
This part is worth slowing down on, because it is what turned a small bug into one that took a week to track down.
Look again at the turbo-rails initializer. The include is wrapped in on_load(:action_cable), and that hook is fired by ActiveSupport.run_load_hooks(:action_cable, ...) at actioncable/lib/action_cable/server/base.rb:107. It runs when the ActionCable::Server::Base constant is loaded, which in practice means the first time ActionCable.server is referenced (a Turbo broadcast, or an Action Cable test helper). Requiring action_cable/engine at boot does not fire it. So the real broadcasts method does not exist in your test ancestor chain until ActionCable::Server::Base has been loaded. Until then, method_missing works and your fixture accessor returns the right record.
Whether ActionCable::Server::Base is loaded before a given test runs depends on eager loading:
# config/environments/test.rb
config.eager_load = ENV['CI'].present?
In CI, eager_load is true, so Zeitwerk eager-loads server/base.rb during boot. The hook fires before any test runs, broadcasts is shadowed everywhere, and the test fails consistently. That part was never flaky.
Locally, eager_load is false, so ActionCable::Server::Base is autoloaded on first reference, only once some test exercises a code path that builds the cable server. Now combine that with two defaults:
# test/test_helper.rb
parallelize(workers: :number_of_processors)
plus Minitest’s randomized test order. Tests are split across forked worker processes in a different distribution every run, in a different order every run. In a run where the worker handling this test happens to load Action Cable first (because some earlier test in that worker did), the shadow is in place and the test fails. In a run where this test executes before anything in its worker loads Action Cable, method_missing still works and it passes.
So the same test, with no change to the code, comes out green or red depending on the random seed and how that run’s parallel workers happened to be partitioned. It behaves like an ordinary flaky test, even though the real cause is a method that only conditionally exists in the ancestor chain.
The fix
Present all four together; they address different layers.
1. Use the documented fixture() accessor (recommended)
Rails anticipated exactly this. There is a public accessor for fixture set names that collide with other methods:
# 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
It is defined just above the private keyword in that file, so it is public, it is a real method, and it does not route through method_missing. It is not subject to this collision:
broadcast = fixture(:broadcasts, :announcement)
This is the fix to reach for. It says explicitly which fixture set you mean, and because it is a real public method it is not subject to the collision.
2. Skip the accessor, query directly
If you would rather not depend on the fixture accessor surface at all:
broadcast = Broadcast.find_by(subject: "Announcement")
Robust and obvious, at the cost of coupling the test to attribute values instead of a fixture label, and an extra query.
3. Rename the fixture set so it cannot collide
Move test/fixtures/broadcasts.yml to a non-colliding name and point it back at the model:
# test/fixtures/email_broadcasts.yml
_fixture:
model_class: Broadcast
announcement:
subject: "Announcement"
Then email_broadcasts(:announcement) goes through method_missing cleanly because nothing defines email_broadcasts. This removes the trap for everyone on the team, but it is invasive and fights the table-name convention.
4. Make the failure deterministic
Even with one of the fixes above in place, the underlying flakiness is worth neutralizing so the next collision fails the same way on every run instead of intermittently. The flakiness comes from a method that only exists after Action Cable has loaded, so force eager loading in the test boot:
# test/test_helper.rb
Rails.application.eager_load!
This does not fix the collision itself. What it does is turn an intermittent failure into a consistent one, which is a real improvement in practice: a test that fails on every run gets investigated and fixed, while a test that only fails one run in five usually gets retried until it passes and then forgotten.
The general lesson
The broadcasts name is not the real issue here. The trap is structural: any fixture set whose name matches a public instance method reachable from your test case will be silently bypassed, because fixture accessors live in method_missing and any real method wins.
How a collision shows up depends entirely on what the colliding method returns. A fixture set named name resolves to the test’s own name and hands back a String; one named hash hands back an Integer. Some of those blow up quickly, others quietly return the wrong thing and fail much later. The broadcasts case is more frustrating than dangerous: the method it collides with returns [], so nothing breaks at the call site, and the error only surfaces later as a NoMethodError on an unrelated line with nothing pointing back to fixtures.
In this case the model was just named Broadcast. Rails pluralizes that into a broadcasts table and a test/fixtures/broadcasts.yml fixture set, so the accessor was broadcasts, the exact name ActionCable::TestHelper defines. There is nothing unusual about that name, which is the whole problem. I hit some version of this every so often: a short, natural model name that happens to match a method Rails or one of its ecosystem gems already defines. The tests start acting up right away, but it still takes a while to track down, because a name collision is rarely the first thing you check. It is worth a second thought before naming a model something generic.
A quick audit for your own suite: derive each fixture set name the way Rails does, then check it against the methods your test case actually responds to. Run it after the app is eager-loaded and after ActionCable::Server::Base has loaded, otherwise the turbo-rails helper is not mixed in yet and the collision you care about will not show up. Note that namespaced fixtures map slashes to underscores (admin/users.yml becomes admin_users), and that a private method shadows the accessor just as effectively as a public one:
# 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
Anything that prints is a fixture set you should only ever read through fixture(:name, :label).