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.
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 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
.
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.
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.
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.
If you love us? You can donate to us via Paypal or buy me a coffee so we can maintain and grow! Thank you!
Donate Us With