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.
Best Answer
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.
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 anArray
as you are aware. In Rails 4.0.x, with ahas_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 theActiveRecord_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. Thesum
method defined onArray
is patched in via Active Support. TheArray#sum
behavior is fundamentally different: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:
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:Now later, when you use
Invoice
in some other part of your code, you can easily stub this if you need to: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
vsbuild
In the example above I used
create(:invoice)
andinvoice.line_items.create(cost: cost)
. However, if you are concerned about DB slowness, you probably could just as easily usebuild(:invoice)
andinvoice.line_items.build(cost: cost)
.Be aware that my use of
create(:invoice)
andbuild(:invoice)
here is in reference to generic "factories", not a reference to a specific gem. You could simply useModel.create
andModel.new
in their place. Additionally, theline_items.create
andline_items.build
are provided by AR and have nothing to do with any factory gems.