How to test a method in an ActiveRecord :: Relation object in rspec?

How to test a method available only for the proxy class of an ActiveRecord relationship in rspec? For example, sum , which will look something like @collection.sum(:attribute)

Here is what I am trying to do:

 @invoice = stub_model(Invoice) @line_item = stub_model(LineItem, {quantity: 1, cost: 10.00, invoice: @invoice}) @invoice.stub(:line_items).and_return([@line_item]) @invoice.line_items.sum(:cost).should eq(10) 

This does not work because @invoice.line_items returns a regular array that sum does not define in the same way as an ActiveRecord :: Relation object does.

Any help is greatly appreciated.

+8
ruby-on-rails activerecord rspec
source share
1 answer

I'm not sure which Rails you are using, so I am using Rails 4.0.x for this example; the principle is still valid for Rails 3.x.

TL; DR: you do not want to use this route.

  • Do not consider model specifications
  • Consider adding domain APIs

You are quickly heading down the bullying / step road. I was on this path, it did not lead to fun. Part of it all comes down to breaking Demeterโ€™s law. Part of it comes down to using the Rails API instead of creating your own domain APIs.

When you request a collection of relationships from an ActiveRecord model, it does not return Array , as you know. In Rails 4.0.x with the has_many association, the return class is: ActiveRecord::Associations::CollectionProxy::ActiveRecord_Associations_CollectionProxy_Model .

Problem # 1: Aborting Invalid Return Value

Here your return type is Array . Although the actual return type is ActiveRecord_Associations_CollectionProxy_Model . In dead end / breadboard land this is not necessarily bad. However, if you intend to use other calls for the object returned by the stub, they must conform to the same API contracts. Otherwise, you are not performing the same behavior.

In this case, the sum method defined in the AR proxy connection actually executes SQL when it runs. The sum method defined on Array is fixed through Active Support. The behavior of Array#sum fundamentally different:

 def sum(identity = 0, &block) if block_given? map(&block).sum(identity) else inject { |sum, element| sum + element } || identity end end 

As you can see, it sums up the elements, not the sum of the requested attribute.

Problem # 2: Validation on Your Muted Object

Another major problem that you have is that you are trying to clarify that you are drowning out what you put out. It does not make sense. The stub item is to return a complete answer. He should not state how he behaves.

What you wrote does not fundamentally differ from:

 invoice = stub_model(Invoice) line_item = stub_model(LineItem, {quantity: 1, cost: 10.00, invoice: invoice}) invoice.stub(:line_items).and_return([line_item]) invoice.line_items.should eq([line_item]) 

If this is not considered a health check, it does not add real value to your specifications.

suggestions

I am not sure what type of specification you are writing here. If this is a more traditional unit test or acceptance test, then I probably won't drown anything. There is not always something wrong with hitting the database sometimes, especially when the thing you are testing is how you interact with it; this is really what you are doing here.

Another thing you can do is start using this to create your own model specific APIs. All this actually means defining interfaces on objects that make sense for your domain, which may or may not be backed up by a database or other resource.

For example, take invoice.line_items.sum(:cost).should eq(10) , this explicitly checks the Rails AR API. In domains, this does not mean anything. However, invoice.subtotal probably means a lot more for your domain:

 # app/models/invoice.rb class Invoice < ActiveRecord::Base def subtotal line_items.sum(:cost) end end # spec/models/invoice_spec.rb # These are unit specs on the model, which directly works with the DB # it probably doesn't make sense to stub things here describe Invoice do specify "the subtotal is the sum of all line item cost" do invoice = create(:invoice) 3.times do |i| cost = (i + 1) * 2 invoice.line_items.create(cost: cost) end expect(invoice.subtotal).to eq 12 end end 

Now that you use Invoice in some other part of your code, you can easily drown it out if you need to:

 # spec/helpers/invoice_helper_spec.rb describe InvoiceHelper do context "requesting the formatted subtotal" do it "returns US dollars to two decimal places" do invoice = double(Invoice, subtotal: 1012) assign(:invoice, invoice) expect(helper.subtotal_in_dollars).to eq "$10.12" end end end 

So, when is it okay to seal model specifications? Well, this is indeed a call to judgment, and it will vary from person to person, and the code base to the code base. However, just because something is in app/models does not mean that it should be an ActiveRecord model. In such cases, it could potentially stop coding domain APIs on collaborators.

EDIT: create vs build

In the above example, I used create(:invoice) and invoice.line_items.create(cost: cost) . However, if you are concerned about slowing down the DB, you could probably easily use build(:invoice) and invoice.line_items.build(cost: cost) .

Remember that my use of create(:invoice) and build(:invoice) here refers to common "factories", not a specific gem. You can simply use Model.create and Model.new instead. In addition, line_items.create and line_items.build provided by AR and have nothing to do with factory stones.

+13
source share

All Articles