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
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.