Por qué tu accesor de fixture de Rails devuelve un Array (y solo a veces)
Un accesor de fixture de Rails como broadcasts(:announcement) debería devolver un registro. A veces devuelve un Array vacío en su lugar, y el test falla más adelante, lejos de la causa real. Además es inestable: puede pasar en local en una ejecución, fallar en la siguiente, y fallar siempre en CI.
La causa raíz es una colisión de nombres de método que Rails 7.1 hizo posible y que turbo-rails desencadena a través de Action Cable. Este artículo cubre el síntoma, cómo diagnosticarlo, y cuatro maneras de arreglarlo. Todo empezó con un test que fallaba así:
NoMethodError: undefined method 'id' for an instance of Array
La línea que señalaba no tenía nada de particular:
broadcast = broadcasts(:announcement)
assert_equal broadcast.id, recipient.broadcast_id
Lo desconcertante: la línea justo encima usaba exactamente el mismo patrón sobre otro fixture set, y esa funcionaba.
channel = broadcast_channels(:main_channel) # works, every time
broadcast = broadcasts(:announcement) # Array, sometimes
Ambas llamadas usan el mismo patrón de accesor, viven en el mismo directorio de fixtures y se ejecutan dentro del mismo test, y sin embargo una devuelve el registro y la otra un Array. Y tampoco era consistente: pasaba en local en algunas ejecuciones, fallaba en otras, y fallaba de forma fiable en CI. Un fallo que afecta a un fixture set pero no a otro, y solo de vez en cuando, es lo que hizo difícil acotarlo.
Broadcast, ahí es donde vive.
El síntoma que no tenía sentido
El primer instinto es pensar en corrupción de la fixture: un broadcasts.yml roto, una clave duplicada, un problema de orden de carga entre fixtures. Nada de eso se sostuvo. El YAML se parseaba bien, y cargar el fixture set directamente devolvía el registro, así que el problema estaba en el accesor, no en los datos.
El segundo instinto es creer que «inestable» significa «problema de timing». No era ni un hilo ni un reloj. El mecanismo es determinista dado un estado de proceso concreto. Simplemente ocurre que el estado de proceso del que depende es en sí mismo no determinista entre ejecuciones. Volvemos a esto más abajo, porque es la parte más interesante.
Cómo resuelve Rails un accesor de fixture
broadcasts(:announcement) parece un método generado, y hasta Rails 7.0 lo era. Desde Rails 7.1, los accesores de fixture se resuelven mediante method_missing en su lugar:
# 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
El punto clave: method_missing solo se dispara cuando no existe ningún método real con ese nombre en ninguna parte de la cadena de ancestros. No hay ningún método broadcasts generado en la clase de test. Mientras nada más defina broadcasts, Ruby no encuentra un método, cae en method_missing, ve broadcasts en fixture_sets y devuelve el registro.
En el momento en que algo define un método de instancia broadcasts real, Ruby lo encuentra primero, method_missing no se ejecuta nunca, y el accesor de fixture queda silenciosamente sorteado.
Esta es la verdadera causa raíz, y vale la pena ser preciso al respecto. Antes de Rails 7.1, fixtures generaba un accesor real con define_method directamente sobre la clase de test. Un método definido en la clase prevalece sobre cualquier cosa que venga de un módulo incluido, así que el accesor broadcasts generado habría ganado frente a ActionCable::TestHelper#broadcasts, y este bug no habría podido ocurrir en absoluto. Rails 7.1 reemplazó esos métodos generados por method_missing (commit 05d80fc24f, “Reduce the memory footprint of fixtures accessors”, porque definir un accesor por fixture set en una suite grande es caro). Esa optimización invirtió silenciosamente la prioridad: sin método real en la clase, ahora gana un método del mismo nombre proveniente de un módulo incluido, y method_missing nunca se alcanza. La colisión es un efecto secundario de una mejora de rendimiento, lo cual es buena parte de por qué nadie piensa en buscarla.
La caza
Entonces: ¿qué define un broadcasts real? Un grep en el código fuente de 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
Ahí está el culpable: un método llamado broadcasts que toma un solo argumento y devuelve un Array (channels_data[channel] ||= []). Pásale :announcement y devuelve la lista de mensajes difundidos a un stream llamado :announcement durante este test, es decir, []. Llamar a .id sobre [] es precisamente lo que lanza undefined method 'id' for an instance of Array. El mensaje de error por fin cobra sentido.
Pero el test no incluye ningún helper de test de Action Cable. ¿Por qué está ese método siquiera en el ámbito? Sigue la cadena de include:
# actioncable/lib/action_cable/test_helper.rb:146
delegate :broadcasts, :clear_messages, to: :pubsub_adapter
ActionCable::TestHelper delega broadcasts al adaptador pubsub de test. Así que cualquier cosa que incluya ActionCable::TestHelper obtiene un método de instancia broadcasts real. ¿Quién lo incluye?
# turbo-rails: lib/turbo/broadcastable/test_helper.rb:7
module Turbo
module Broadcastable
module TestHelper
include ActionCable::TestHelper
Y turbo-rails lo inyecta en todos los casos de test:
# 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
La cadena completa, de la que no escribiste ni una línea:
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 -> []
Con config/cable.yml configurado como adapter: test en el entorno de test, pubsub_adapter es el adaptador de test, así que el broadcasts delegado devuelve [] en lugar de lanzar una excepción en el punto de llamada. Eso es exactamente por qué pasó desapercibido durante meses en lugar de ser detectado la primera vez que se ejecutó.
Por qué era inestable
Esta parte merece que vayamos despacio, porque es la que convirtió un bug pequeño en uno que costó una semana rastrear.
Mira otra vez el initializer de turbo-rails. El include está envuelto en on_load(:action_cable), y ese hook lo dispara ActiveSupport.run_load_hooks(:action_cable, ...) en actioncable/lib/action_cable/server/base.rb:107. Se ejecuta cuando la constante ActionCable::Server::Base se carga, lo que en la práctica significa la primera vez que se referencia ActionCable.server (una difusión de Turbo, o un helper de test de Action Cable). Hacer require "action_cable/engine" al arrancar no lo dispara. Así que el método broadcasts real no existe en la cadena de ancestros de tu test hasta que ActionCable::Server::Base se ha cargado. Hasta entonces, method_missing funciona y tu accesor de fixture devuelve el registro correcto.
Que ActionCable::Server::Base se cargue antes de que un test dado se ejecute depende del eager loading:
# config/environments/test.rb
config.eager_load = ENV['CI'].present?
En CI, eager_load es true, así que Zeitwerk hace eager load de server/base.rb al arrancar. El hook se dispara antes de cualquier test, broadcasts queda eclipsado en todas partes, y el test falla de forma consistente. Esa parte nunca fue inestable.
En local, eager_load es false, así que ActionCable::Server::Base se autocarga en la primera referencia, solo una vez que algún test recorre un camino de código que construye el servidor cable. Combina eso con dos valores por defecto:
# test/test_helper.rb
parallelize(workers: :number_of_processors)
más el orden de tests aleatorio de Minitest. Los tests se reparten entre procesos worker forkeados con una distribución distinta en cada ejecución, en un orden distinto en cada ejecución. En una ejecución donde el worker que maneja este test carga Action Cable primero (porque un test anterior en ese worker lo hizo), el eclipsado está en su sitio y el test falla. En una ejecución donde este test se ejecuta antes de que nada en su worker cargue Action Cable, method_missing aún funciona y pasa.
Así que el mismo test, sin ningún cambio en el código, sale verde o rojo según la semilla aleatoria y cómo se particionaron los workers en paralelo en esa ejecución. Se comporta como un test inestable cualquiera, aunque la causa real es un método que solo existe condicionalmente en la cadena de ancestros.
El arreglo
Los cuatro se presentan juntos; atacan capas distintas.
1. Usar el accesor documentado fixture() (recomendado)
Rails anticipó exactamente esto. Hay un accesor público para nombres de fixture set que colisionan con otros métodos:
# 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
Está definido justo encima de la palabra clave private en ese archivo, así que es público, es un método real, y no pasa por method_missing. No le afecta esta colisión:
broadcast = fixture(:broadcasts, :announcement)
Este es el arreglo al que recurrir. Dice explícitamente a qué fixture set te refieres, y al ser un método público real, no le afecta la colisión.
2. Saltarse el accesor, consultar directamente
Si prefieres no depender en absoluto de la superficie de los accesores de fixture:
broadcast = Broadcast.find_by(subject: "Announcement")
Robusto y obvio, a costa de acoplar el test a valores de atributos en lugar de a una etiqueta de fixture, y de una consulta extra.
3. Renombrar el fixture set para que no pueda colisionar
Mueve test/fixtures/broadcasts.yml a un nombre no conflictivo y vuélvelo a apuntar al modelo:
# test/fixtures/email_broadcasts.yml
_fixture:
model_class: Broadcast
announcement:
subject: "Announcement"
Entonces email_broadcasts(:announcement) pasa limpiamente por method_missing porque nada define email_broadcasts. Esto elimina la trampa para todo el equipo, pero es invasivo y va en contra de la convención de nombre de tabla.
4. Hacer que el fallo sea determinista
Incluso con uno de los arreglos anteriores en su sitio, la inestabilidad subyacente vale la pena neutralizarla para que la próxima colisión falle de la misma forma en cada ejecución en lugar de de forma intermitente. La inestabilidad viene de un método que solo existe después de que Action Cable se cargue, así que fuerza el eager loading en el arranque de los tests:
# test/test_helper.rb
Rails.application.eager_load!
Esto no arregla la colisión en sí. Lo que hace es convertir un fallo intermitente en uno consistente, lo cual es una mejora real en la práctica: un test que falla en cada ejecución se investiga y se arregla, mientras que un test que solo falla una vez de cada cinco normalmente se reintenta hasta que pasa, y luego se olvida.
La lección general
El problema real aquí no es el nombre broadcasts. La trampa es estructural: cualquier fixture set cuyo nombre coincida con un método de instancia público alcanzable desde tu caso de test será silenciosamente sorteado, porque los accesores de fixture viven en method_missing y cualquier método real gana.
Cómo se manifiesta una colisión depende por completo de lo que devuelva el método en conflicto. Un fixture set llamado name se resuelve al name del propio test y devuelve un String; uno llamado hash devuelve un Integer. Algunos de esos revientan rápido, otros devuelven silenciosamente lo equivocado y fallan mucho después. El caso broadcasts es más molesto que peligroso: el método en conflicto devuelve [], así que nada se rompe en el punto de llamada, y el error solo aflora más tarde como un NoMethodError en una línea no relacionada, sin nada que apunte a las fixtures.
En este caso el modelo simplemente se llamaba Broadcast. Rails lo pluraliza en una tabla broadcasts y un fixture set test/fixtures/broadcasts.yml, así que el accesor era broadcasts, exactamente el nombre que define ActionCable::TestHelper. No hay nada inusual en ese nombre, y ese es todo el problema. Me topo con alguna versión de esto cada cierto tiempo: un nombre de modelo corto y natural que casualmente coincide con un método que Rails o uno de los gems de su ecosistema ya define. Los tests empiezan a comportarse mal de inmediato, pero aun así cuesta rato rastrearlo, porque una colisión de nombres rara vez es lo primero que compruebas. Vale la pena pensárselo dos veces antes de darle a un modelo un nombre demasiado genérico.
Una auditoría rápida para tu propia suite: deriva cada nombre de fixture set igual que lo hace Rails, y luego compáralo con los métodos a los que tu caso de test realmente responde. Ejecútalo después de que la aplicación haya hecho eager load y después de que ActionCable::Server::Base se haya cargado; de lo contrario el helper de turbo-rails aún no está inyectado y la colisión que te importa no aparecerá. Ten en cuenta que las fixtures con namespace convierten las barras en guiones bajos (admin/users.yml se vuelve admin_users), y que un método privado eclipsa el accesor con la misma eficacia que uno público:
# 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
Todo lo que se imprima es un fixture set que solo deberías leer mediante fixture(:name, :label).