40 votes

Utilisation de factory_girl dans Rails avec des associations ayant des contraintes uniques. Obtention d'erreurs dupliquées

Je travaille sur un projet Rails 2.2 pour le mettre à jour. Je remplace les fixtures existantes par des factories (en utilisant factory_girl) et j'ai rencontré quelques problèmes. Le problème concerne les modèles qui représentent des tables avec des données de consultation. Lorsque je crée un panier avec deux produits qui ont le même type de produit, chaque produit créé recrée le même type de produit. Cette erreur provient d'une validation unique sur le modèle ProductType.

Démonstration du problème

Ceci provient d'un test unitaire où je crée un panier et l'assemble par morceaux. J'ai dû faire cela pour contourner le problème. Cela démontre quand même le problème. Je vais vous expliquer.

cart = Factory(:cart)
cart.cart_items = [Factory(:cart_item, 
                           :cart => cart, 
                           :product => Factory(:added_users_product)),
                   Factory(:cart_item, 
                           :cart => cart, 
                           :product => Factory(:added_profiles_product))]

Les deux produits ajoutés sont du même type et lorsque chaque produit est créé, il recrée le type de produit et crée des doublons.

L'erreur qui est générée est : "ActiveRecord::RecordInvalid : Validation failed : Le nom a déjà été pris, le code a déjà été pris".

Solution de rechange

La solution de contournement pour cet exemple consiste à remplacer le type de produit utilisé et à transmettre une instance spécifique afin qu'une seule instance soit utilisée. Le "add_product_type" est récupéré au début et transmis pour chaque article du panier.

cart = Factory(:cart)
prod_type = Factory(:add_product_type)   #New
cart.cart_items = [Factory(:cart_item,
                           :cart => cart,
                           :product => Factory(:added_users_product,
                                               :product_type => prod_type)), #New
                   Factory(:cart_item,
                           :cart => cart,
                           :product => Factory(:added_profiles_product,
                                               :product_type => prod_type))] #New

Question

Quelle est la meilleure façon d'utiliser factory_girl avec des associations de type "liste de sélection" ?

Je comme pour que la définition de l'usine contienne tout au lieu de devoir l'assembler dans le test, bien que je puisse vivre avec.

Contexte et détails supplémentaires

usines/produit.rb

# Declare ProductTypes

Factory.define :product_type do |t|
  t.name "None"
  t.code "none"
end

Factory.define :sub_product_type, :parent => :product_type do |t|
  t.name "Subscription"
  t.code "sub"
end

Factory.define :add_product_type, :parent => :product_type do |t|
  t.name "Additions"
  t.code "add"
end

# Declare Products

Factory.define :product do |p|
  p.association :product_type, :factory => :add_product_type
  #...
end

Factory.define :added_profiles_product, :parent => :product do |p|
  p.association :product_type, :factory => :add_product_type
  #...
end

Factory.define :added_users_product, :parent => :product do |p|
  p.association :product_type, :factory => :add_product_type
  #...
end

Le "code" de ProductType a pour but de permettre à l'application de leur donner une signification particulière. Le modèle ProductType ressemble à quelque chose comme ceci :

class ProductType < ActiveRecord::Base
  has_many :products

  validates_presence_of :name, :code
  validates_uniqueness_of :name, :code
  #...
end

usines/cart.rb

# Define Cart Items

Factory.define :cart_item do |i|
  i.association :cart
  i.association :product, :factory => :test_product
  i.quantity 1
end

Factory.define :cart_item_sub, :parent => :cart_item do |i|
  i.association :product, :factory => :year_sub_product
end

Factory.define :cart_item_add_profiles, :parent => :cart_item do |i|
  i.association :product, :factory => :add_profiles_product
end

# Define Carts

# Define a basic cart class. No cart_items as it creates dups with lookup types.
Factory.define :cart do |c|
  c.association :account, :factory => :trial_account
end

Factory.define :cart_with_two_different_items, :parent => :cart do |o|
  o.after_build do |cart|
    cart.cart_items = [Factory(:cart_item, 
                               :cart => cart, 
                               :product => Factory(:year_sub_product)),
                       Factory(:cart_item, 
                               :cart => cart, 
                               :product => Factory(:added_profiles_product))]
  end
end

Lorsque j'essaie de définir le panier avec deux articles du même type de produit, j'obtiens la même erreur que celle décrite ci-dessus.

Factory.define :cart_with_two_add_items, :parent => :cart do |o|
  o.after_build do |cart|
    cart.cart_items = [Factory(:cart_item,
                               :cart => cart,
                               :product => Factory(:added_users_product)),
                       Factory(:cart_item,
                               :cart => cart,
                               :product => Factory(:added_profiles_product))]
  end
end

45voto

CubaLibre Points 1065

Pour info, vous pouvez aussi utiliser la fonction initialize_with dans votre factory et vérifiez si l'objet existe déjà, puis ne le créez pas à nouveau. La solution utilisant un lambda (c'est génial, mais !) est la réplication de la logique déjà présente dans find_or_create_by. Cela fonctionne également pour les associations où la :league est créée par une usine associée.

FactoryGirl.define do
  factory :league, :aliases => [:euro_cup] do
    id 1
    name "European Championship"
    rank 30
    initialize_with { League.find_or_create_by_id(id)}
  end
end

31voto

d2vid Points 585

J'ai rencontré le même problème et j'ai ajouté un lambda en haut de mon fichier de fabriques qui implémente un modèle singleton, qui régénère également le modèle si la base de données a été effacée depuis la dernière série de tests/spécifications :

saved_single_instances = {}
#Find or create the model instance
single_instances = lambda do |factory_key|
  begin
    saved_single_instances[factory_key].reload
  rescue NoMethodError, ActiveRecord::RecordNotFound  
    #was never created (is nil) or was cleared from db
    saved_single_instances[factory_key] = Factory.create(factory_key)  #recreate
  end

  return saved_single_instances[factory_key]
end

Ensuite, en utilisant vos usines d'exemple, vous pouvez utiliser un attribut paresseux factory_girl pour exécuter le lambda

Factory.define :product do |p|
  p.product_type  { single_instances[:add_product_type] }
  #...this block edited as per comment below
end

Voilà !

2voto

Mark Eric Points 334

La réponse courte est "non", Factory girl n'a pas de moyen plus propre de le faire. Il me semble avoir vérifié cela sur les forums de Factory girl.

Cependant, j'ai trouvé une autre réponse pour moi-même. Elle implique une autre sorte de solution de contournement mais rend le tout beaucoup plus propre.

L'idée est de modifier les modèles qui représentent les tables de consultation pour créer l'entrée requise si elle est manquante. C'est correct car le code s'attend à ce que des entrées spécifiques existent. Voici un exemple du modèle modifié.

class ProductType < ActiveRecord::Base
  has_many :products

  validates_presence_of :name, :code
  validates_uniqueness_of :name, :code

  # Constants defined for the class.
  CODE_FOR_SUBSCRIPTION = "sub"
  CODE_FOR_ADDITION = "add"

  # Get the ID for of the entry that represents a trial account status.
  def self.id_for_subscription
    type = ProductType.find(:first, :conditions => ["code = ?", CODE_FOR_SUBSCRIPTION])
    # if the type wasn't found, create it.
    if type.nil?
      type = ProductType.create!(:name => 'Subscription', :code => CODE_FOR_SUBSCRIPTION)
    end
    # Return the loaded or created ID
    type.id
  end

  # Get the ID for of the entry that represents a trial account status.
  def self.id_for_addition
    type = ProductType.find(:first, :conditions => ["code = ?", CODE_FOR_ADDITION])
    # if the type wasn't found, create it.
    if type.nil?
      type = ProductType.create!(:name => 'Additions', :code => CODE_FOR_ADDITION)
    end
    # Return the loaded or created ID
    type.id
  end
end

La méthode de classe statique "id_for_addition" chargera le modèle et l'ID s'ils sont trouvés, sinon elle les créera.

L'inconvénient est que la méthode "id_for_addition" peut ne pas être claire quant à ce qu'elle fait par son nom. Il faudra peut-être changer cela. Le seul autre impact sur le code pour une utilisation normale est un test supplémentaire pour voir si le modèle a été trouvé ou non.

Cela signifie que le code de l'usine pour créer le produit peut être modifié comme ceci...

Factory.define :added_users_product, :parent => :product do |p|
  #p.association :product_type, :factory => :add_product_type
  p.product_type_id { ProductType.id_for_addition }
end

Cela signifie que le code Factory modifié peut ressembler à ceci...

Factory.define :cart_with_two_add_items, :parent => :cart do |o|
  o.after_build do |cart|
    cart.cart_items = [Factory(:cart_item_add_users, :cart => cart),
                       Factory(:cart_item_add_profiles, :cart => cart)]
  end
end

C'est exactement ce que je voulais. Je peux maintenant exprimer proprement ma fabrique et mon code de test.

Un autre avantage de cette approche est que les données de la table de consultation n'ont pas besoin d'être ensemencées ou alimentées lors des migrations. Elles se gèrent d'elles-mêmes pour les bases de données de test et de production.

2voto

satyajit Points 313

Ces problèmes seront éliminés lorsque les singletons seront introduits dans les usines - actuellement, ils sont de -10%. http://github.com/roderickvd/factory_girl/tree/singletons Question - http://github.com/thoughtbot/factory_girl/issues#issue/16

2voto

Mika Points 353

J'ai eu une situation similaire. J'ai fini par utiliser mon seeds.rb pour définir les singletons et ensuite demander le seeds.rb dans le spec_helper.rb pour créer les objets dans la base de données de test. Ensuite, je peux simplement rechercher l'objet approprié dans les fabriques.

db/seeds.rb

RegionType.find_or_create_by_region_type('community')
RegionType.find_or_create_by_region_type('province')

spec/spec_helper.rb

require "#{Rails.root}/db/seeds.rb"

spec/factory.rb

FactoryGirl.define do
  factory :region_community, class: Region do
    sequence(:name) { |n| "Community#{n}" }
    region_type { RegionType.find_by_region_type("community") }
  end
end

Prograide.com

Prograide est une communauté de développeurs qui cherche à élargir la connaissance de la programmation au-delà de l'anglais.
Pour cela nous avons les plus grands doutes résolus en français et vous pouvez aussi poser vos propres questions ou résoudre celles des autres.

Powered by:

X