Logo Questions Linux Laravel Mysql Ubuntu Git Menu
 

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

How do I test a method available only to an ActiveRecord relation proxy class in rspec? Like for example sum which would look something like @collection.sum(:attribute)

Here is what I'm 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 doesn't work because @invoice.line_items returns a regular array that doesn't define sum in the same way as an ActiveRecord::Relation object does.

Any help is greatly appreciated.

like image 679
BlissOfBeing Avatar asked May 08 '14 08:05

BlissOfBeing


1 Answers

I'm not sure which Rails you are on so I'll use Rails 4.0.x for this example; the principle still holds for Rails 3.x.

TL;DR: You don't want to take this route.

  • Consider not stubbing model specs
  • Consider adding domain specific APIs

You are rapidly heading down the road of over mocking/stubbing. I have been down this road, it does not lead to fun. Part of all of this comes down to violating the Law of Demeter. Part of it comes down to using the Rails APIs instead of creating your own domain APIs.

When you request an relation collection from an ActiveRecord model it does not return an Array as you are aware. In Rails 4.0.x, with a has_many association, the class which is returned is: ActiveRecord::Associations::CollectionProxy::ActiveRecord_Associations_CollectionProxy_Model.

Issue #1: Stubbing the wrong return value

Here your return type is an Array. While the actual return type is the ActiveRecord_Associations_CollectionProxy_Model. In stub/mock land, this isn't necessarily a bad thing. However, if you intend to use other calls on the object returned by the stub they need to match the same API contracts. Otherwise, you're not stubbing the same behavior.

In this case, the sum method defined on the AR association proxy actually executes SQL when it runs. The sum method defined on Array is patched in via Active Support. The Array#sum behavior is 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 the elements, not the sum of the requested attribute.

Issue #2: Asserting on your stub'd object

The other main problem you have, is you are attempting to spec that you're stub returns what you stubbed. This doesn't make sense. The point of a stub is to return a canned answer. It's not to assert on how it behaves.

What you wrote isn't fundamentally different 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])

Unless this is supposed to be a sanity check, it adds no real value to your specs.

Suggestions

I'm not sure what type of spec you are writing here. If this is a more traditional unit test or an acceptance test, then I probably wouldn't stub anything. There isn't necessarily anything wrong with hitting a database at times, especially when the thing you are testing is how you interact with it; which is really what you are doing here.

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

For example, take your invoice.line_items.sum(:cost).should eq(10), this is clearly testing the Rails AR API. In domain terms it means nothing really. However, invoice.subtotal probably means a lot more to 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 later, when you use Invoice in some other part of your code, you can easily stub this 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 it is ok to stub model specs? Well, that's really a judgement call, and will vary from person to person, and code base to code base. However, just because something is in app/models doesn't mean it has to be an ActiveRecord model. In those cases, it's potentially fine to stub domain APIs on collaborators.

EDIT: create vs build

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

Be aware that my use of create(:invoice) and build(:invoice) here is in reference to generic "factories", not a reference to a specific gem. You could simply use Model.create and Model.new in their place. Additionally, the line_items.create and line_items.build are provided by AR and have nothing to do with any factory gems.

like image 104
Aaron K Avatar answered Nov 07 '22 06:11

Aaron K