web-dev-qa-db-fra.com

Stratégie FactoryGirl build_stubbed avec une association has_many

Étant donné une relation has_many standard entre deux objets. Pour un exemple simple, allons-y avec:

class Order < ActiveRecord::Base
  has_many :line_items
end

class LineItem < ActiveRecord::Base
  belongs_to :order
end

Ce que j'aimerais faire, c'est générer une commande tronquée avec une liste des éléments de campagne tronqués.

FactoryGirl.define do
  factory :line_item do
    name 'An Item'
    quantity 1
  end
end

FactoryGirl.define do
  factory :order do
    ignore do
      line_items_count 1
    end

    after(:stub) do |order, evaluator|
      order.line_items = build_stubbed_list(:line_item, evaluator.line_items_count, :order => order)
    end
  end
end

Le code ci-dessus ne fonctionne pas car Rails veut appeler save sur la commande lorsque line_items est affecté et FactoryGirl lève une exception: RuntimeError: stubbed models are not allowed to access the database

Alors, comment pouvez-vous (ou est-il possible) de générer un objet tronqué où sa collection has_may est également tronquée?

36
Jared

TL; DR

FactoryGirl essaie d'être utile en faisant une très grande hypothèse quand il crée ses objets "stub". A savoir que: vous avez un id, ce qui signifie que vous n'êtes pas un nouvel enregistrement, et que vous êtes donc déjà persisté!

Malheureusement, ActiveRecord l'utilise pour décider s'il doit garder la persistance à jour . Le modèle tronqué tente donc de conserver les enregistrements dans la base de données.

Veuillez ne pas essayer de caler les talons/maquettes RSpec dans les usines FactoryGirl. Cela mélange deux philosophies de stubbing différentes sur le même objet. Choisissez l'un ou l'autre.

Les maquettes RSpec ne sont censées être utilisées que pendant certaines parties du cycle de vie des spécifications. Les déplacer dans l'usine crée un environnement qui masquera la violation de la conception. Les erreurs qui en découlent seront déroutantes et difficiles à localiser.

Si vous consultez la documentation pour inclure RSpec dans say test/unit , vous pouvez voir qu'il fournit des méthodes pour garantir que les mocks sont correctement configurés et détruits entre les tests. Mettre les moqueries dans les usines ne garantit pas que cela se produira.

Il y a plusieurs options ici:

  • N'utilisez pas FactoryGirl pour créer vos talons; utiliser une bibliothèque de stubbing (rspec-mocks, minitest/mocks, mocha, flexmock, rr ou etc)

    Si vous souhaitez conserver la logique d'attribut de votre modèle dans FactoryGirl, c'est parfait. Utilisez-le à cette fin et créez le talon ailleurs:

    stub_data = attributes_for(:order)
    stub_data[:line_items] = Array.new(5){
      double(LineItem, attributes_for(:line_item))
    }
    order_stub = double(Order, stub_data)
    

    Oui, vous devez créer manuellement les associations. Ce n'est pas une mauvaise chose, voir ci-dessous pour plus de discussion.

  • Effacez le champ id

    after(:stub) do |order, evaluator|
      order.id = nil
      order.line_items = build_stubbed_list(
        :line_item,
        evaluator.line_items_count,
        order: order
      )
    end
    
  • Créez votre propre définition de new_record?

    factory :order do
      ignore do
        line_items_count 1
        new_record true
      end
    
      after(:stub) do |order, evaluator|
        order.define_singleton_method(:new_record?) do
          evaluator.new_record
        end
        order.line_items = build_stubbed_list(
          :line_item,
          evaluator.line_items_count,
          order: order
        )
      end
    end
    

Que se passe t-il ici?

OMI, ce n'est généralement pas une bonne idée d'essayer de créer un _ "tronqué" has_many association avec FactoryGirl. Cela a tendance à créer un code plus étroitement couplé et potentiellement de nombreux objets imbriqués créés inutilement.

Pour comprendre cette position et ce qui se passe avec FactoryGirl, nous devons jeter un oeil à quelques choses:

  • La couche/gem de persistance de la base de données (c'est-à-dire ActiveRecord, Mongoid, DataMapper, ROM, etc.)
  • Toutes les bibliothèques de stubbing/mocking (mintest/mocks, rspec, mocha, etc.)
  • Les moquettes/talons servent

La couche de persistance de la base de données

Chaque couche de persistance de base de données se comporte différemment. En fait, beaucoup se comportent différemment entre les versions principales. FactoryGirl essaye pour ne pas faire d'hypothèses sur la configuration de cette couche. Cela leur donne le plus de flexibilité sur le long terme.

Hypothèse: Je suppose que vous utilisez ActiveRecord pour le reste de cette discussion.

Au moment où j'écris ceci, la version actuelle GA de ActiveRecord est 4.1.0. Lorsque vous configurez un has_many association dessus, il y aalotquevaactivé .

Ceci est également légèrement différent dans les anciennes versions AR. C'est très différent dans Mongoid, etc. Il n'est pas raisonnable de s'attendre à ce que FactoryGirl comprenne les subtilités de toutes ces gemmes, ni les différences entre les versions. Il se trouve que has_many rédacteur de l'association tente de maintenir la persistance à jour .

Vous pensez peut-être: "mais je peux définir l'inverse avec un talon"

FactoryGirl.define do
  factory :line_item do
    association :order, factory: :order, strategy: :stub
  end
end

li = build_stubbed(:line_item)

Oui, c'est vrai. Bien que ce soit simplement parce que AR a décidé pas depersister . Il s'avère que ce comportement est une bonne chose. Sinon, il serait très difficile de configurer des objets temporaires sans frapper fréquemment la base de données. En outre, il permet d'enregistrer plusieurs objets en une seule transaction, annulant la transaction entière en cas de problème.

Maintenant, vous pensez peut-être: "Je peux totalement ajouter des objets à un has_many sans toucher à la base de données "

order = Order.new
li = order.line_items.build(name: 'test')
puts LineItem.count                   # => 0
puts Order.count                      # => 0
puts order.line_items.size            # => 1

li = LineItem.new(name: 'bar')
order.line_items << li
puts LineItem.count                   # => 0
puts Order.count                      # => 0
puts order.line_items.size            # => 2

li = LineItem.new(name: 'foo')
order.line_items.concat(li)
puts LineItem.count                   # => 0
puts Order.count                      # => 0
puts order.line_items.size            # => 3

order = Order.new
order.line_items = Array.new(5){ |n| LineItem.new(name: "test#{n}") }
puts LineItem.count                   # => 0
puts Order.count                      # => 0
puts order.line_items.size            # => 5

Oui, mais ici order.line_items est vraiment un ActiveRecord::Associations::CollectionProxy . Il définit son propre build , #<< et #concat méthodes. Bien sûr, ceux-ci délèguent vraiment tous à l'association définie, qui pour has_many sont les méthodes équivalentes: ActiveRecord::Associations::CollectionAssocation#build et ActiveRecord::Associations::CollectionAssocation#concat . Celles-ci prennent en compte l'état actuel de l'instance de modèle de base afin de décider s'il doit persister maintenant ou plus tard.

Tout ce que FactoryGirl peut vraiment faire ici est de laisser le comportement de la classe sous-jacente définir ce qui doit se produire. En fait, cela vous permet d'utiliser FactoryGirl pour générer n'importe quelle classe , pas seulement les modèles de base de données.

FactoryGirl essaie d'aider un peu avec la sauvegarde d'objets. C'est principalement du côté create des usines. Par leur page wiki sur interaction avec ActiveRecord :

... [une usine] enregistre d'abord les associations afin que les clés étrangères soient correctement définies sur les modèles dépendants. Pour créer une instance, il appelle new sans aucun argument, attribue chaque attribut (y compris les associations), puis appelle save !. factory_girl ne fait rien de spécial pour créer des instances ActiveRecord. Il n’interagit pas avec la base de données ni n’étend ActiveRecord ou vos modèles de quelque manière que ce soit.

Attendre! Vous avez peut-être remarqué, dans l'exemple ci-dessus, j'ai glissé ce qui suit:

order = Order.new
order.line_items = Array.new(5){ |n| LineItem.new(name: "test#{n}") }
puts LineItem.count                   # => 0
puts Order.count                      # => 0
puts order.line_items.size            # => 5

Oui, c'est vrai. Nous pouvons définir order.line_items= à un tableau et il ne persiste pas! Alors qu'est-ce qui donne?

Les bibliothèques de stubbing/mocking

Il existe de nombreux types différents et FactoryGirl fonctionne avec tous. Pourquoi? Parce que FactoryGirl ne fait rien avec aucun d'entre eux. Il ignore complètement de quelle bibliothèque vous disposez.

N'oubliez pas que vous ajoutez la syntaxe FactoryGirl à votre bibliothèque de test de choix . Vous n'ajoutez pas votre bibliothèque à FactoryGirl.

Donc, si FactoryGirl n'utilise pas votre bibliothèque préférée, que fait-elle?

Les fins Les moqueurs/moignons servent

Avant d'arriver aux détails sous le capot, nous devons définir quoia"stub"is et son objectif prév :

Les talons fournissent des réponses prédéfinies aux appels effectués pendant le test, ne répondant généralement pas du tout à ce qui est en dehors de ce qui est programmé pour le test. Les talons peuvent également enregistrer des informations sur les appels, comme un talon de passerelle de messagerie qui se souvient des messages qu'il a "envoyés", ou peut-être seulement du nombre de messages qu'il a "envoyés".

ceci est subtilement différent d'un "faux":

Mocks ...: objets pré-programmés avec des attentes qui forment une spécification des appels qu'ils sont censés recevoir.

Les talons permettent de configurer des collaborateurs avec des réponses prédéfinies. S'en tenir uniquement à l'API publique des collaborateurs que vous touchez pour le test spécifique maintient les talons légers et petits.

Sans bibliothèque de "stubbing", vous pouvez facilement créer vos propres stubs:

stubbed_object = Object.new
stubbed_object.define_singleton_method(:name) { 'Stubbly' }
stubbed_object.define_singleton_method(:quantity) { 123 }

stubbed_object.name       # => 'Stubbly'
stubbed_object.quantity   # => 123

Étant donné que FactoryGirl est complètement indépendant de la bibliothèque en ce qui concerne leurs "talons", c'est l'approche qu'ils adoptent .

En regardant l'implémentation FactoryGirl v.4.4.0, nous pouvons voir que les méthodes suivantes sont toutes tronquées lorsque vous build_stubbed:

  • persisted?
  • new_record?
  • save
  • destroy
  • connection
  • reload
  • update_attribute
  • update_column
  • created_at

Ce sont tous très ActiveRecord-y. Cependant, comme vous l'avez vu avec has_many, c'est une abstraction assez fuyante. La surface de l'API publique ActiveRecord est très grande. Il n'est pas tout à fait raisonnable de s'attendre à ce qu'une bibliothèque la couvre entièrement.

Pourquoi le has_many l'association ne fonctionne pas avec le stub FactoryGirl?

Comme indiqué ci-dessus, ActiveRecord vérifie son état pour décider s'il doit garder la persistance à jour . En raison de la définition tronquée de new_record? définissant un has_many déclenchera une action de base de données.

def new_record?
  id.nil?
end

Avant de lancer quelques correctifs, je veux revenir à la définition d'un stub:

Les talons fournissent des réponses prédéfinies aux appels effectués pendant le test, généralement ne répondant pas du tout à quoi que ce soit en dehors de ce qui est programmé pour le test . Les talons peuvent également enregistrer des informations sur les appels, comme un talon de passerelle de messagerie qui se souvient des messages qu'il a "envoyés", ou peut-être seulement du nombre de messages qu'il a "envoyés".

L'implémentation FactoryGirl d'un stub viole ce principe. Comme il n'a aucune idée de ce que vous allez faire dans votre test/spécification, il essaie simplement d'empêcher l'accès à la base de données.

Correctif n ° 1: n'utilisez pas FactoryGirl pour créer des talons

Si vous souhaitez créer/utiliser des stubs, utilisez une bibliothèque dédiée à cette tâche. Comme il semble que vous utilisiez déjà RSpec, utilisez sa fonction double (et la nouvelle vérification instance_double , class_double , ainsi que object_double dans RSpec 3). Ou utilisez Mocha, Flexmock, RR ou toute autre chose.

Vous pouvez même lancer votre propre fabrique de stub super simple (oui, il y a des problèmes avec cela, c'est simplement un exemple d'un moyen facile de créer un objet avec des réponses prédéfinies):

require 'ostruct'
def create_stub(stubbed_attributes)
  OpenStruct.new(stubbed_attributes)
end

FactoryGirl facilite la création de 100 objets de modèle lorsque vous en avez vraiment besoin 1. Bien sûr, il s'agit d'un problème d'utilisation responsable; comme toujours un grand pouvoir vient créer une responsabilité. Il est très facile d'ignorer les associations profondément imbriquées, qui ne font pas vraiment partie d'un talon.

De plus, comme vous l'avez remarqué, l'abstraction "stub" de FactoryGirl est un peu fuyante, vous forçant à comprendre à la fois son implémentation et les internes de votre couche de persistance de base de données. L'utilisation d'une bibliothèque stubbing devrait vous libérer complètement de cette dépendance.

Si vous souhaitez conserver la logique d'attribut de votre modèle dans FactoryGirl, c'est parfait. Utilisez-le à cette fin et créez le talon ailleurs:

stub_data = attributes_for(:order)
stub_data[:line_items] = Array.new(5){
  double(LineItem, attributes_for(:line_item))
}
order_stub = double(Order, stub_data)

Oui, vous devez configurer manuellement les associations. Bien que vous ne configuriez que les associations dont vous avez besoin pour le test/la spécification. Vous n'obtenez pas les 5 autres dont vous n'avez pas besoin.

C'est une chose que le fait d'avoir une vraie bibliothèque de stub permet de clarifier explicitement. Il s'agit de vos tests/spécifications vous donnant des commentaires sur vos choix de conception. Avec une configuration comme celle-ci, un lecteur de la spécification peut poser la question: "Pourquoi avons-nous besoin de 5 éléments de campagne?" Si c'est important pour la spécification, tant mieux c'est juste à l'avant et évident . Sinon, il ne devrait pas être là.

La même chose vaut pour ceux une longue chaîne de méthodes appelée un seul objet, ou une chaîne de méthodes sur les objets suivants, il est probablement temps de s'arrêter. La loi de déméter est là pour vous aider, pas pour vous gêner.

Correctif n ° 2: effacez le champ id

C'est plus un hack. Nous savons que le stub par défaut définit un id. Ainsi, nous le supprimons simplement.

after(:stub) do |order, evaluator|
  order.id = nil
  order.line_items = build_stubbed_list(
    :line_item,
    evaluator.line_items_count,
    order: order
  )
end

Nous ne pouvons jamais avoir un stub qui retourne un id ET configure un has_many association. La définition de new_record? que la configuration FactoryGirl empêche complètement cela.

Correctif n ° 3: créez votre propre définition de new_record?

Ici, nous séparons le concept d'un id d'où le stub est un new_record?. Nous poussons cela dans un module afin que nous puissions le réutiliser dans d'autres endroits.

module SettableNewRecord
  def new_record?
    @new_record
  end

  def new_record=(state)
    @new_record = !!state
  end
end

factory :order do
  ignore do
    line_items_count 1
    new_record true
  end

  after(:stub) do |order, evaluator|
    order.singleton_class.prepend(SettableNewRecord)
    order.new_record = evaluator.new_record
    order.line_items = build_stubbed_list(
      :line_item,
      evaluator.line_items_count,
      order: order
    )
  end
end

Nous devons encore l'ajouter manuellement pour chaque modèle.

116
Aaron K

J'ai vu cette réponse flotter, mais j'ai rencontré le même problème que vous aviez: FactoryGirl: remplir une stratégie de construction préservant de nombreuses relations

Le moyen le plus propre que j'ai trouvé est de bloquer explicitement les appels d'association.

require 'rspec/mocks/standalone'

FactoryGirl.define do
  factory :order do
    ignore do
      line_items_count 1
    end

    after(:stub) do |order, evaluator|
      order.stub(line_items).and_return(FactoryGirl.build_stubbed_list(:line_item, evaluator.line_items_count, :order => order))
    end
  end
end

J'espère que cela pourra aider!

12
Bryce

J'ai trouvé la solution de Bryce la plus élégante mais elle produit un avertissement de dépréciation concernant la nouvelle syntaxe allow().

Afin d'utiliser la nouvelle syntaxe (plus propre), j'ai fait ceci:

MISE À JOUR 06/05/2014: ma première proposition était d'utiliser une méthode API privée, merci à Aaraon K pour une solution beaucoup plus agréable, veuillez lire le commentaire pour une discussion plus approfondie

#spec/support/initializers/factory_girl.rb
...
#this line enables us to use allow() method in factories
FactoryGirl::SyntaxRunner.include(RSpec::Mocks::ExampleMethods)
...

 #spec/factories/order_factory.rb
...
FactoryGirl.define do
  factory :order do
    ignore do
      line_items_count 1
    end

    after(:stub) do |order, evaluator|
      items = FactoryGirl.build_stubbed_list(:line_item, evaluator.line_items_count, :order => order)
      allow(order).to receive(:line_items).and_return(items)
    end
  end
end
...
1
systho