如何测试(ActiveRecord)对象的相等性

65
在Ruby 1.9.2和Rails 3.0.3中,我试图测试两个Friend对象(该类继承自ActiveRecord::Base)之间的对象相等性。
这两个对象是相等的,但测试失败:
Failure/Error: Friend.new(name: 'Bob').should eql(Friend.new(name: 'Bob'))

expected #<Friend id: nil, event_id: nil, name: 'Bob', created_at: nil, updated_at: nil>
     got #<Friend id: nil, event_id: nil, name: 'Bob', created_at: nil, updated_at: nil>

(compared using eql?)

仅仅为了好玩,我也测试了对象的身份(identity),结果失败了,这是我预料中的。

Failure/Error: Friend.new(name: 'Bob').should equal(Friend.new(name: 'Bob'))

expected #<Friend:2190028040> => #<Friend id: nil, event_id: nil, name: 'Bob', created_at: nil, updated_at: nil>
     got #<Friend:2190195380> => #<Friend id: nil, event_id: nil, name: 'Bob', created_at: nil, updated_at: nil>

Compared using equal?, which compares object identity,
but expected and actual are not the same object. Use
'actual.should == expected' if you don't care about
object identity in this example.

有人能向我解释为什么第一个对象相等性测试失败,以及如何成功地断言这两个对象是相等的吗?

6个回答

57

Rails有意将等式检查委托给标识列。 如果您想知道两个AR对象是否包含相同的内容,则比较在两者上调用#attributes的结果。


4
在这种情况下,由于两个实例都没有被保存,因此身份列为 nileql?() 检查属性的值和类型。nil.class == nil.classtruenil == nil 也为 true,因此 OP 的第一个示例仍应该返回 true。你的答案没有解释为什么它返回 false。 - Jazz
4
它不仅盲目地比较ID,只有当ID具有意义时才会进行比较。正如Andy Lindeman的回答中所提到的:“新记录定义上与任何其他记录都不同”。 - Lambart

48

请参阅API文档,了解 ActiveRecord::Base 中的 ==(别名为eql?)操作

如果比较对象是同一个对象,则返回true;或者比较对象与self相同类型且self具有ID且其等于比较对象的id,则返回true。

请注意,按定义新记录与任何其他记录都不同,除非其他记录正是接收者本身。此外,如果使用select获取现有记录并将ID留空,则需要自行处理,否则将返回false。

还要注意,销毁记录会在模型实例中保留其ID,因此已删除的模型仍可进行比较。


1
Rails 3.2.8的API文档链接已更新:http://www.rubydoc.info/docs/rails/3.2.8/frames另外,值得注意的是eql?被重写了,但别名equal?没有被重写,它仍然比较object_id - Ben Simpson
这个回答比当前标记为正确的答案更好。==的文档解释了我需要知道的所有要点,让我能够弄清楚Rails如何测试对象相等性。 - kapad

25
如果你想基于它们的属性比较两个模型实例,你可能想要从比较中排除某些不相关的属性,比如:idcreated_atupdated_at。(我认为这些更多地是记录的元数据而不是记录本身的数据。)
当你比较两个新记录(未保存的记录)时,这可能无关紧要(因为idcreated_atupdated_at在保存之前均为nil),但有时需要比较一个已保存的对象与一个未保存的对象(这种情况下,==会返回false,因为nil != 5)。或者想要比较两个已保存的对象,以查找它们是否包含相同的数据(所以ActiveRecord的==运算符不起作用,因为如果它们具有不同的id则返回false,即使它们在其他方面相同)。
我的解决方法是在希望使用属性进行比较的模型中添加以下内容:
  def self.attributes_to_ignore_when_comparing
    [:id, :created_at, :updated_at]
  end

  def identical?(other)
    self. attributes.except(*self.class.attributes_to_ignore_when_comparing.map(&:to_s)) ==
    other.attributes.except(*self.class.attributes_to_ignore_when_comparing.map(&:to_s))
  end

那么在我的规范中,我可以编写如下易读且简洁的代码:

Address.last.should be_identical(Address.new({city: 'City', country: 'USA'}))

我打算分叉active_record_attributes_equality gem并将其更改为使用此行为,以便可以更轻松地重复使用。

但是我有一些问题:

  • 是否已经存在这样的gem?
  • 应该将方法命名为什么?我认为覆盖现有的==操作符不是一个好主意,所以现在我称之为identical?。但也许像practically_identical?attributes_eql?之类的东西会更准确,因为它不检查它们是否严格相等(有些属性允许不同)...
  • attributes_to_ignore_when_comparing太啰嗦了。注意,如果要使用gem的默认设置,每个模型都需要显式添加此设置。也许可以通过类宏覆盖默认值,例如ignore_for_attributes_eql :last_signed_in_at, :updated_at

欢迎留言...

更新:与其分叉active_record_attributes_equality,我写了一个全新的gem,active_record_ignored_attributes,可在http://github.com/TylerRick/active_record_ignored_attributeshttp://rubygems.org/gems/active_record_ignored_attributes 上找到。


2
 META = [:id, :created_at, :updated_at, :interacted_at, :confirmed_at]

 def eql_attributes?(original,new)
   original = original.attributes.with_indifferent_access.except(*META)
   new = new.attributes.symbolize_keys.with_indifferent_access.except(*META)
   original == new
 end

 eql_attributes? attrs, attrs2

2
我创建了一个 RSpec 匹配器,专门用于此类比较,非常简单但有效。
在这个文件中: spec/support/matchers.rb 你可以实现这个匹配器...
RSpec::Matchers.define :be_a_clone_of do |model1|
  match do |model2|
    ignored_columns = %w[id created_at updated_at]
    model1.attributes.except(*ignored_columns) == model2.attributes.except(*ignored_columns)
  end
end

最初的回答:
之后,在编写规范时,您可以通过以下方式使用它…
item = create(:item) # FactoryBot gem
item2 = item.dup

expect(item).to be_a_clone_of(item2)
# True

有用的链接:

https://relishapp.com/rspec/rspec-expectations/v/2-4/docs/custom-matchers/define-matcher https://github.com/thoughtbot/factory_bot

原始答案翻译成“最初的回答”。


1
如果你和我一样正在寻找一个关于 Minitest 的答案,那么这里有一个自定义方法,用于断言两个对象的属性是否相等。
它假设你总是想要排除 idcreated_atupdated_at 属性,但如果你愿意,可以覆盖这种行为。
我喜欢保持我的 test_helper.rb 整洁,所以创建了一个名为 test/shared/custom_assertions.rb 的文件,内容如下。
module CustomAssertions
  def assert_attributes_equal(original, new, except: %i[id created_at updated_at])
    extractor = proc { |record| record.attributes.with_indifferent_access.except(*except) }
    assert_equal extractor.call(original), extractor.call(new)
  end
end

然后修改你的test_helper.rb文件,将其包含进去,这样你就可以在测试中访问它了。
require 'shared/custom_assertions'

class ActiveSupport::TestCase
  include CustomAssertions
end

基本用法:

test 'comments should be equal' do
  assert_attributes_equal(Comment.first, Comment.second)
end

如果您想覆盖它忽略的属性,则可以通过传递一个包含字符串或符号的数组以及 except 参数来实现:
test 'comments should be equal' do
  assert_attributes_equal(
    Comment.first, 
    Comment.second, 
    except: %i[id created_at updated_at edited_at]
  )
end

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接