FactoryGirl因模型

时间:2015-08-02 15:45:02

标签: ruby-on-rails rspec factory-bot

我有一个模型Foo,其state_code作为外键。 States表是一个(或多或少)静态表,用于保存50个州的代码和名称,以及其他美国邮政编码(例如" PR"波多黎各)。我选择使用state_code作为状态的主键和Foo上的外键,而不是state_id。它对人类更好,并简化了我想调用状态代码的视图逻辑。 (编辑 - 只是为了澄清:我并不是说从视图中调用代码来访问模型;我的意思是将状态显示为@foo.state_code似乎比{{1}更简单}。)

Foo与模特Bar也有@foo.state.state_code的关系。两个模型规范都传递了有效工厂的规范,但由于某些原因,在运行构建Bar实例的功能规范时,由于与has_many

相关的外键问题导致测试爆炸

我为所有模型传递了模型规格,包括有效工厂的测试。但是,每当我尝试为Bar'创建测试对象时,我都会遇到麻烦。使用state_code炸毁了Foo中build的外键错误(尽管事实上Foo工厂明确指定了一个确认在状态中作为state_code存在的值)。使用state_code作为Bar对象似乎不会持久存在该对象。

模特:

build_stubbed

下面的工厂为我的Foo和Bar模型传递绿色,所以从模型的角度看工厂似乎很好:

# models/foo.rb
class Foo < ActiveRecord
  belongs_to :state, foreign_key: 'state_code', primary_key: 'state_code'
  has_many :bars
  validates :state_code, presence: true, length: { is: 2 }

  # other code omitted...
end

# models/state.rb
class State < ActiveRecord
  self.primary_key = 'state_code'
  has_many :foos, foreign_key: 'state_code'
  validates :state_code, presence: true, uniqueness: true, length: { is: 2 } 

  # other code omitted...
end

# models/bar.rb
class Bar < ActiveRecord
  belongs_to :foo

  # other code omitted
end

......基本规格是:

# spec/factores/foo_bar_factory.rb
require 'faker'
require 'date'

FactoryGirl.define do
  factory :foo do
    name { Faker::Company.name }
    city { Faker::Address.city }
    website { Faker::Internet.url }
    state_code { 'AZ' } # Set code for Arizona b/c doesn't matter which state
  end

  factory :bar do
    name { Faker::Name.name }
    website_url { Faker::Internet.url }
    # other columns omitted
    association :foo
  end
end

此规范通过,没有任何问题。但是如果我使用# spec/models/foo_spec.rb require 'rails_helper' describe Foo, type: :model do let(:foo) { build(:foo) } it "has a valid factory" do expect(foo).to be_valid end # code omitted... end # spec/models/bar_spec.rb require 'rails_helper' describe Bar, type: :model do let(:bar) { build_stubbed(:bar) } # have to build_stubbed - build causes error it "has a valid factory" do expect(bar).to be_valid end end 代替build(:bar)而不是build_stubbed,我会在外键上出错:

1) Bar has a valid factory
     Failure/Error: let(:bar) { build(:bar) }
ActiveRecord::InvalidForeignKey:
       PG::ForeignKeyViolation: ERROR:  insert or update on table "bars" violates foreign key constraint "fk_rails_3dd3a7c4c3"
       DETAIL:  Key (state_code)=(AZ) is not present in table "states".

代码&#39; AZ&#39;肯定是在州表中,所以我不清楚它失败的原因。

在功能规范中,我尝试创建持久存储在数据库中的bar实例,因此我可以测试它们是否在#index,#show和#edit actions中正确显示。但是我似乎无法让它正常工作。功能规范失败:     #spec / features / bar_pages_spec.rb     要求&#39; rails_helper&#39;

feature "Bar pages" do
  context "when signed in as admin" do
    let!(:bar_1) { build_stubbed(:bar) }
    let!(:bar_2) { build_stubbed(:bar) }
    let!(:bar_3) { build_stubbed(:bar) }

  # code omitted...

   scenario "clicking manage bar link shows all bars" do
     visit root_path
     click_link "Manage bars"
     save_and_open_page

     expect(page).to have_css("tr td a", text: bar_1.name)
     expect(page).to have_css("tr td a", text: bar_2.name)
     expect(page).to have_css("tr td a", text: bar_3.name)
   end
 end

此规范失败,并显示一条消息,指出没有匹配项。使用save_and_open_page不会在视图中显示预期的项目。 (我有一个包含开发数据的工作页面,所以我知道逻辑实际上按预期工作)。 The thoughtbot post on build_stubbed表示它应该持久化对象:

  

它使对象看起来像是被持久化,创建的   与build_stubbed策略的关联(而build仍然使用   创建),并存储一些与之交互的方法   如果你打电话给数据库并加注。

...但在我的规范中它似乎没有这样做。在此规范中尝试使用build代替build_stubbed会产生上述相同的外键错误。

我真的被困在这里了。模型似乎有有效的工厂并通过所有规格。但是功能规格要么会破坏外键关系,要么似乎不会在视图之间保留build_stubbed对象。感觉就像一团糟,但我无法找到解决问题的正确方法。我在实践中有实际的,工作的观点,做我期望的 - 但我希望测试覆盖率有效。

更新

我回去并更新了所有模型代码,以删除state_code的自然键。我遵循了@ Max的所有建议。 Foo表现在使用state_id作为states的外键;我按照推荐的那样复制了app/models/concerns/belongs_to_state.rb的代码等等。

更新了schema.rb:

create_table "foos", force: :cascade do |t|
  # columns omitted
  t.integer  "state_id"
end

create_table "states", force: :cascade do |t|
  t.string   "code",       null: false
  t.string   "name"
end

add_foreign_key "foos", "states"

模型规格通过,我的一些更简单的功能规格通过。我现在意识到只有在创建了多个Foo对象时才会出现问题。发生这种情况时,由于列:code

上的唯一性约束,第二个对象失败
Failure/Error: let!(:foo_2) { create(:foo) }
     ActiveRecord::RecordInvalid:
       Validation failed: Code has already been taken

我试图在工厂中直接设置:state_id列:foo以避免调用:state工厂。 E.g。

# in factory for foo:
state_id { 1 }

# generates following error on run:
Failure/Error: let!(:foo_1) { create(:foo) }
     ActiveRecord::InvalidForeignKey:
       PG::ForeignKeyViolation: ERROR:  insert or update on table "foos" violates foreign key constraint "fk_rails_5f3d3f12c3"
       DETAIL:  Key (state_id)=(1) is not present in table "states".

显然state_id不在州,因为它在状态上是id,在foos中是state_id。另一种方法:

# in factory for foo:
state { 1 }    # alternately w/ same error ->  state 1

ActiveRecord::AssociationTypeMismatch:
   State(#70175500844280) expected, got Fixnum(#70175483679340)

或者:

# in factory for foo:
state { State.first }

ActiveRecord::RecordInvalid:
   Validation failed: State can't be blank

我真正想做的就是创建一个Foo对象的实例,让它包含与states表中某个状态的关系。我不希望对states表进行大量更改 - 它只是一个参考。

DON&#39; T 需要创建一个新状态。我只需要使用states表中state_id列中的66个值之一填充Foo对象上的外键:id。从概念上讲,:foo的工厂理想情况下只需为:state_id选择1到66之间的整数值。它适用于控制台:

irb(main):001:0> s = Foo.new(name: "Test", state_id: 1)
=> #<Foo id: nil, name: "Test", city: nil, created_at: nil, updated_at: nil,  zip_code: nil, state_id: 1>
irb(main):002:0> s.valid?
  State Load (0.6ms)  SELECT  "states".* FROM "states" WHERE "states"."id" = $1 LIMIT 1  [["id", 1]]
  State Exists (0.8ms)  SELECT  1 AS one FROM "states" WHERE ("states"."code" = 'AL' AND "states"."id" != 1) LIMIT 1
=> true

我现在唯一能看到的方法是摆脱:codestates列的唯一性限制。或者 - 删除foosstates之间的外键约束,并让Rails强制执行该关系。

很抱歉这篇文章......

2 个答案:

答案 0 :(得分:3)

我将成为一个痛苦的人,并认为约定可能比开发人员的便利性和感知可读性更重要。

Rails的一大优点是强大的约定允许我们打开任何项目并快速找出正在发生的事情(前提是原作者不是完全黑客)。尝试使用PHP项目。

其中一个约定是外键后缀为_id。 FactoryGirl等许多其他组件都依赖于这些惯例。

我还认为,如果您的应用程序曾在美国境外使用过,那么使用州代码作为主要ID会导致问题。当您需要跟踪加拿大各省或印度的州和地区时会发生什么?你打算怎么处理不可避免的冲突?即使您认为这可能不是今天的交易,也要记住要求会随着时间而变化。

我会将其建模为:

create_table "countries", force: :cascade do |t|
    t.string   "code",       null: false # ISO 3166-1 alpha-2 or alpha-3
    t.datetime "created_at", null: false
    t.datetime "updated_at", null: false
end

add_index "countries", ["code"], name: "index_countries_on_code"

create_table "states", force: :cascade do |t|
    t.integer  "country_id"
    t.string   "code",       null: false
    t.string   "name",       null: false
    t.datetime "created_at", null: false
    t.datetime "updated_at", null: false
end

add_index "states", ["code"], name: "index_states_on_code"
add_index "states", ["country_id", "code"], name: "index_states_on_country_id_and_code"
add_index "states", ["country_id"], name: "index_states_on_country_id"
  

“并简化了我想调用状态代码的视图逻辑”

我认为如果可以避免的话,你根本不应该从你的视图中进行数据库调用。从您的控制器预先查询并将数据传递到您的视图。它使优化查询和避免N + 1问题变得更加简单。

使用演示者或辅助方法来帮助管理复杂性。必须执行State.find_by(code: 'AZ')代替State.find('AZ')的轻微不便很可能不像您想象的那么重要。

加入:

这是您在FactoryGirl中正确使用关联的方法。考虑这个解决方案的简单性,最后一个论点是为什么你的自定义外键安排可能会导致更多的悲伤而不是方便。

模型:

class State < ActiveRecord::Base
  # Only the State model should be validating its attributes.
  # You have a major violation of concerns.
  validates_uniqueness_of :state_code
  validates_length_of :state_code, is: 2
end
# app/models/concerns/belongs_to_state.rb
module BelongsToState

  extend ActiveSupport::Concern

  included do
    belongs_to :state
    validates :state, presence: true
    validates_associated :state # will not let you save a Foo or Bar if the state is invalid.
  end

  def state_code
    state.state_code
  end

  def state_code= code
    self.assign_attributes(state: State.find_by!(state_code: code))
  end
end
class Foo < ActiveRecord::Base
  include BelongsToState
end
class Bar < ActiveRecord::Base
  include BelongsToState
end

工厂:

# spec/factories/foos.rb
require 'faker'
FactoryGirl.define do
  factory :foo do
    name { Faker::Company.name }
    city { Faker::Address.city }
    website { Faker::Internet.url }
    state
  end
end

# spec/factories/states.rb
FactoryGirl.define do
  factory :state do
    state_code "AZ"
    name "Arizona"
  end
end

这些规范使用shoulda-matchers作为极端的succint验证示例:

require 'rails_helper'

RSpec.describe Foo, type: :model do

  let(:foo) { build(:foo) }
  it { should validate_presence_of :state }

  it 'validates the associated state' do
    foo.state.state_code = 'XYZ'
    foo.valid?
    expect(foo.errors).to have_key :state
  end

  describe '#state_code' do
    it 'returns the state code' do
      expect(foo.state_code).to eq 'AZ'
    end
  end

  describe '#state_code=' do
    let!(:vt) { State.create(state_code: 'VT') }
    it 'allows you to set the state with a string' do
      foo.state_code = 'VT'
      expect(foo.state).to eq vt
    end
  end
end
# spec/models/state_spec.rb
require 'rails_helper'

RSpec.describe State, type: :model do
  it { should validate_length_of(:state_code).is_equal_to(2) }
  it { should validate_uniqueness_of(:state_code) }
end

https://github.com/maxcal/sandbox/tree/31773581

您还需要使用FactoryGirl.create而不是build_stubbed在功能,控制器或集成规范中

build_stubbed不会将模型持久保存到数据库中,在这些情况下,您需要控制器能够从数据库加载记录。

如果可能,您应该避免在功能规格中使用CSS选择器。功能规范应该从用户的POV描述您的应用程序。

feature "Bar management" do
  context "as an Admin" do
    let!(:bars){ 3.times.map { create(:bar) } }

    background do
      visit root_path
      click_link "Manage bars"
    end

    scenario "I should see all the bars on the management page" do
      # just testing a sampling is usually good enough
      expect(page).to have_link bars.first.name 
      expect(page).to have_link bars.last.name 
    end  

    scenario "I should be able to edit a Bar" do
      click_link bars.first.name
      fill_in('Name', with: 'Moe´s tavern')
      # ...
    end  
  end
end

答案 1 :(得分:0)

这里发生了很多事情,但就FactoryGirl问题引发了Foo和State之间的外键关系,我已经明白了。

@Max是关于在states表上使用自然键作为主键的问题。它不遵循Rails惯例,并导致一些混淆问题,例如可能需要验证Foo表上的外键(例如长度2)。

但即使在修复了链接Rails友好密钥(:state_id上的表作为foos上的外键,:id作为states上的主键之后 - 我仍然找不到使用:foo工厂创建多个Foo对象实例的方法。当我试图将整数值“插入”state_id时,或者:state工厂在第二个实例上失败时,它会失败,说明代码已经存在。 (有关尝试和相关失败的详细信息,请参阅问题中的更新。)

唯一的方法似乎是删除State上的唯一性验证,或消除数据库层的外键关系(Postgres 9.4)。我决定不想做前者。在考虑后者时,我意识到我确实不需要数据库中的外键约束。 states表仅用于提供一致的状态代码列表作为参考点。如果由于某种原因我要删除这个表,那么我想要销毁所有的Foo记录是不正确的。他们基本上是独立的,国家只是Foo的一个属性。我简要地考虑将状态信息放入常量,但是meh。

为我删除数据库级外键约束固定的东西。

bin/rails g migration RemoveForeignKeyStatesFromFoos

class RemoveForeignKeyStatesFromFoos < ActiveRecord::Migration
  def change
    remove_foreign_key :foos, :states
  end
end

这使我的:state_id表格中的foos列保持不变,但从我的schema.rb中删除了行add_foreign_key "foos", "states"

bin/rails g migration AddIndexToStateIdInFoos

class AddIndexToStateIdInFoos < ActiveRecord::Migration
  def change
    add_index :foos, :state_id
  end
end

...将行add_index "foos", ["state_id"], name: "index_foos_on_state_id", using: :btree添加到我的架构中。

迁移后,我最初错误地删除了:state工厂,认为我不需要创建新状态。经过测试中的一些令人头疼的事情后,我意识到测试数据库通常不会被rake db:seed播种 - 所以我的测试由于Module::DelegationError的奇怪错误而失败了。我没有构建一个脚本来为状态设定测试dB,而是修改了工厂,并将关联保留在:foo工厂。

# spec/factories/foo_factory.rb
FactoryGirl.define do
  factory :foo do
    # columns omitted
    state
  end

  factory :state do
    code { Faker::Address.state_abbr }
    code { Faker::Address.state }
  end
end

此时,Rails仍然成功验证模型中的has_manybelongs_to关系(未更改)。

我理解add_foreign_key方法对于Rails来说相对较新,as of 4.2。我将这种关系的事实与在数据库层建立实际外键约束的需要混为一谈。

来自Rails Guide for ActiveRecord Associations

  

您有责任维护数据库架构以匹配您的   关联。在实践中,这意味着两件事,取决于什么   您正在创建的一些关联。对于belongs_to协会你   需要创建外键,以及has_and_belongs_to_many   您需要创建适当的连接表的关联。

在这种情况下使用术语“外键”对于Rails来说似乎意味着与Postgres不同。只要belongs_to表中的列与约定[parent_table_name]_id匹配,Rails就会非常满意。这可以通过显式添加列或在迁移中使用references来实现:

  

使用t.integer:supplier_id使外键命名明显   明确。在当前版本的Rails中,您可以抽象出来   使用t.references实现细节:供应商改为

就我而言,这已足够 - 实际的外键不是必需的。