Pourquoi votre accesseur de fixture Rails renvoie un Array (et seulement parfois)

Un accesseur de fixture Rails comme broadcasts(:announcement) devrait renvoyer un enregistrement. Parfois, il renvoie un Array vide à la place, et le test échoue plus loin, loin de la cause réelle. C’est aussi instable : ça peut passer en local sur une exécution, échouer sur la suivante, et échouer à chaque fois en CI.

La cause racine est une collision de noms de méthode que Rails 7.1 a rendue possible et que turbo-rails déclenche via Action Cable. Cet article couvre le symptôme, comment le diagnostiquer, et quatre façons de le corriger. Tout a commencé avec un test qui échouait ainsi :

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

La ligne pointée n’avait rien de remarquable :

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

Le plus déroutant : la ligne juste au-dessus utilisait exactement le même schéma sur un autre fixture set, et celle-là fonctionnait.

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

Les deux appels utilisent le même schéma d’accesseur, vivent dans le même répertoire de fixtures et s’exécutent dans le même test, pourtant l’un renvoie l’enregistrement et l’autre un Array. Et ce n’était pas constant non plus : ça passait en local sur certaines exécutions, échouait sur d’autres, et échouait de façon fiable en CI. Un échec qui touche un fixture set mais pas un autre, et seulement de temps en temps, voilà ce qui a rendu le problème difficile à cerner.

À propos du projet Au cas où vous vous le demanderiez, ceci est sorti de la construction de sendbroadcast.net, une plateforme d'e-mail auto-hébergée. Si le projet auquel appartient ce modèle Broadcast vous intéresse, c'est là qu'il vit.

Le symptôme qui n’avait aucun sens

Le premier réflexe est de penser à une corruption de fixture : un broadcasts.yml cassé, une clé en double, un problème d’ordre de chargement entre fixtures. Rien de tout cela ne tenait. Le YAML se parsait correctement, et charger le fixture set directement renvoyait l’enregistrement, donc le problème était dans l’accesseur, pas dans les données.

Le deuxième réflexe est de croire qu’« instable » signifie « problème de timing ». Ce n’était ni un thread ni une horloge. Le mécanisme est déterministe pour un état de processus donné. Il se trouve simplement que l’état de processus dont il dépend est lui-même non déterministe d’une exécution à l’autre. On y revient plus bas, car c’est la partie la plus intéressante.

Comment Rails résout un accesseur de fixture

broadcasts(:announcement) ressemble à une méthode générée, et jusqu’à Rails 7.0 ça en était une. Depuis Rails 7.1, les accesseurs de fixture sont résolus via method_missing à la place :

# 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

Le point clé : method_missing ne se déclenche que lorsqu’aucune vraie méthode de ce nom n’existe nulle part dans la chaîne d’ancêtres. Il n’y a pas de méthode broadcasts générée sur la classe de test. Tant que rien d’autre ne définit broadcasts, Ruby ne trouve pas de méthode, retombe sur method_missing, voit broadcasts dans fixture_sets, et renvoie l’enregistrement.

Dès que quelque chose définit une vraie méthode d’instance broadcasts, Ruby la trouve en premier, method_missing ne s’exécute jamais, et l’accesseur de fixture est silencieusement court-circuité.

C’est ça, la véritable cause racine, et ça vaut la peine d’être précis. Avant Rails 7.1, fixtures générait un vrai accesseur avec define_method directement sur la classe de test. Une méthode définie sur la classe l’emporte sur tout ce qui vient d’un module inclus, donc l’accesseur broadcasts généré aurait gagné face à ActionCable::TestHelper#broadcasts, et ce bug n’aurait pas pu se produire du tout. Rails 7.1 a remplacé ces méthodes générées par method_missing (commit 05d80fc24f, “Reduce the memory footprint of fixtures accessors”, parce que définir un accesseur par fixture set dans une grande suite coûte cher). Cette optimisation a discrètement inversé la priorité : sans vraie méthode sur la classe, une méthode de même nom venant d’un module inclus l’emporte désormais, et method_missing n’est jamais atteint. La collision est un effet de bord d’une amélioration de performance, ce qui explique en grande partie pourquoi personne ne pense à la chercher.

La traque

Donc : qu’est-ce qui définit un vrai broadcasts ? Un grep dans les sources 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

Voilà le coupable : une méthode nommée broadcasts qui prend un seul argument et renvoie un Array (channels_data[channel] ||= []). Passez-lui :announcement et elle renvoie la liste des messages diffusés vers un stream nommé :announcement pendant ce test, c’est-à-dire []. Appeler .id sur [] est précisément ce qui lève undefined method 'id' for an instance of Array. Le message d’erreur prend enfin tout son sens.

Mais le test n’inclut aucun helper de test Action Cable. Pourquoi cette méthode est-elle même dans la portée ? Suivez la chaîne d’include :

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

ActionCable::TestHelper délègue broadcasts à l’adaptateur pubsub de test. Donc tout ce qui inclut ActionCable::TestHelper obtient une vraie méthode d’instance broadcasts. Qui l’inclut ?

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

Et turbo-rails l’injecte dans chaque cas 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 chaîne complète, dont vous n’avez écrit aucune ligne :

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 -> []

Avec config/cable.yml réglé sur adapter: test dans l’environnement de test, pubsub_adapter est l’adaptateur de test, donc le broadcasts délégué renvoie [] au lieu de lever une exception au point d’appel. C’est exactement pour ça qu’il est passé inaperçu pendant des mois au lieu d’être attrapé dès la première exécution.

Pourquoi c’était instable

Cette partie mérite qu’on ralentisse, car c’est elle qui a transformé un petit bug en un bug qui a pris une semaine à traquer.

Regardez à nouveau l’initializer de turbo-rails. L’include est enveloppé dans on_load(:action_cable), et ce hook est déclenché par ActiveSupport.run_load_hooks(:action_cable, ...) dans actioncable/lib/action_cable/server/base.rb:107. Il s’exécute quand la constante ActionCable::Server::Base est chargée, ce qui en pratique signifie la première fois que ActionCable.server est référencé (une diffusion Turbo, ou un helper de test Action Cable). Faire require "action_cable/engine" au démarrage ne le déclenche pas. Donc la vraie méthode broadcasts n’existe pas dans la chaîne d’ancêtres de votre test tant que ActionCable::Server::Base n’a pas été chargé. Jusque-là, method_missing fonctionne et votre accesseur de fixture renvoie le bon enregistrement.

Que ActionCable::Server::Base soit chargé avant qu’un test donné ne s’exécute dépend de l’eager loading :

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

En CI, eager_load vaut true, donc Zeitwerk fait l’eager load de server/base.rb au démarrage. Le hook se déclenche avant tout test, broadcasts est masqué partout, et le test échoue de façon constante. Cette partie-là n’a jamais été instable.

En local, eager_load vaut false, donc ActionCable::Server::Base est autoloadé à la première référence, seulement une fois qu’un test emprunte un chemin de code qui construit le serveur cable. Combinez ça avec deux réglages par défaut :

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

plus l’ordre de tests aléatoire de Minitest. Les tests sont répartis sur des processus workers forkés selon une distribution différente à chaque exécution, dans un ordre différent à chaque exécution. Dans une exécution où le worker qui gère ce test charge Action Cable en premier (parce qu’un test antérieur dans ce worker l’a fait), le masquage est en place et le test échoue. Dans une exécution où ce test s’exécute avant que quoi que ce soit dans son worker ne charge Action Cable, method_missing fonctionne encore et il passe.

Ainsi, le même test, sans aucun changement de code, ressort vert ou rouge selon la graine aléatoire et la façon dont les workers parallèles ont été partitionnés lors de cette exécution. Il se comporte comme un test instable ordinaire, alors même que la vraie cause est une méthode qui n’existe que de façon conditionnelle dans la chaîne d’ancêtres.

Le correctif

Les quatre sont présentés ensemble ; ils s’attaquent à des couches différentes.

1. Utiliser l’accesseur documenté fixture() (recommandé)

Rails avait précisément anticipé ça. Il existe un accesseur public pour les noms de fixture set qui entrent en collision avec d’autres méthodes :

# 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

Elle est définie juste au-dessus du mot-clé private dans ce fichier, donc elle est publique, c’est une vraie méthode, et elle ne passe pas par method_missing. Elle n’est pas concernée par cette collision :

broadcast = fixture(:broadcasts, :announcement)

C’est le correctif à privilégier. Il dit explicitement de quel fixture set vous parlez, et parce que c’est une vraie méthode publique, il n’est pas concerné par la collision.

2. Ignorer l’accesseur, requêter directement

Si vous préférez ne pas dépendre du tout de la surface des accesseurs de fixture :

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

Robuste et évident, au prix d’un couplage du test à des valeurs d’attributs plutôt qu’à un libellé de fixture, et d’une requête supplémentaire.

3. Renommer le fixture set pour qu’il ne puisse pas entrer en collision

Déplacez test/fixtures/broadcasts.yml vers un nom non conflictuel et reliez-le au modèle :

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

announcement:
  subject: "Announcement"

Alors email_broadcasts(:announcement) passe proprement par method_missing parce que rien ne définit email_broadcasts. Cela supprime le piège pour toute l’équipe, mais c’est invasif et ça va à l’encontre de la convention de nom de table.

4. Rendre l’échec déterministe

Même avec l’un des correctifs ci-dessus en place, l’instabilité sous-jacente mérite d’être neutralisée pour que la prochaine collision échoue de la même façon à chaque exécution plutôt que par intermittence. L’instabilité vient d’une méthode qui n’existe qu’après le chargement d’Action Cable, donc forcez l’eager loading au démarrage des tests :

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

Ça ne corrige pas la collision elle-même. Ce que ça fait, c’est transformer un échec intermittent en un échec constant, ce qui est une amélioration concrète en pratique : un test qui échoue à chaque exécution est investigué et corrigé, tandis qu’un test qui n’échoue qu’une fois sur cinq est généralement relancé jusqu’à ce qu’il passe, puis oublié.

La leçon générale

Le vrai problème ici n’est pas le nom broadcasts. Le piège est structurel : tout fixture set dont le nom correspond à une méthode d’instance publique atteignable depuis votre cas de test sera silencieusement court-circuité, parce que les accesseurs de fixture vivent dans method_missing et que toute vraie méthode l’emporte.

La façon dont une collision se manifeste dépend entièrement de ce que renvoie la méthode en conflit. Un fixture set nommé name se résout vers le name du test lui-même et renvoie une String ; un nommé hash renvoie un Integer. Certains explosent vite, d’autres renvoient silencieusement la mauvaise chose et échouent bien plus tard. Le cas broadcasts est plus agaçant que dangereux : la méthode en conflit renvoie [], donc rien ne casse au point d’appel, et l’erreur ne fait surface que plus tard, sous la forme d’un NoMethodError sur une ligne sans rapport, sans rien qui pointe vers les fixtures.

Dans ce cas, le modèle était simplement nommé Broadcast. Rails le met au pluriel pour en faire une table broadcasts et un fixture set test/fixtures/broadcasts.yml, donc l’accesseur était broadcasts, le nom exact que définit ActionCable::TestHelper. Il n’y a rien d’inhabituel dans ce nom, et c’est tout le problème. Je tombe sur une variante de ça de temps en temps : un nom de modèle court et naturel qui correspond par hasard à une méthode que Rails ou l’un des gems de son écosystème définit déjà. Les tests commencent à mal se comporter tout de suite, mais ça prend quand même du temps à traquer, parce qu’une collision de noms est rarement la première chose qu’on vérifie. Ça vaut la peine d’y réfléchir à deux fois avant de donner à un modèle un nom trop générique.

Un audit rapide pour votre propre suite : dérivez chaque nom de fixture set comme le fait Rails, puis comparez-le aux méthodes auxquelles votre cas de test répond réellement. Lancez-le après que l’application a été eager loadée et après que ActionCable::Server::Base a été chargé, sinon le helper de turbo-rails n’est pas encore injecté et la collision qui vous intéresse n’apparaîtra pas. Notez que les fixtures avec namespace transforment les slashs en underscores (admin/users.yml devient admin_users), et qu’une méthode privée masque l’accesseur tout aussi efficacement qu’une méthode publique :

# 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

Tout ce qui s’affiche est un fixture set que vous ne devriez lire que via fixture(:name, :label).