We came up with the following solution:
class GivesCreditToPreferredCustomers def self.for_large_orders(sales_amount, added_credit) preferred_customers = Customer.has_large_purchases(sales_amount) preferred_customers.each do |customer| customer.add_credit added_credit end end end class Customer attr_reader :total_credit def self.has_large_purchases(sales_amount) puts "AR query to find buyers with large purchases" end def add_credit(amount) @total_credit = 0 if @total_credit.nil? @total_credit += amount end end describe GivesCreditToPreferredCustomers do specify "for large orders" do sales_amount = 10000 credit_given = 100 found_customer = Customer.new Customer.stub(:has_large_purchases) \ .and_return [found_customer] GivesCreditToPreferredCustomers \ .for_large_orders(sales_amount, credit_given) found_customer.total_credit.should == credit_given end end
Take a look at the lines where the Customer's :has_large_purchases method is being stubbed: "Customer.stub(:has_large_purchases).and_return([found_customer])".
Everything is passing there, even though I have not specified any arguments. Of course: when you don't specify arguments, RSpec will take any arguments (or no arguments) and return the canned response.
A couple of months passes by and a new requirement comes in: we need to look at only the last 3 months of purchases, otherwise the company is giving away too much credit to its customers. The look back period is the same to all customers, it's safe to put it in the GivesCreditToPreferredCustomers class.
You would obviously start with modifying the spec, but your co-worker wants to get this done really quick and updates the application code like this:
class GivesCreditToPreferredCustomers LOOK_BACK_PERIOD = 3 def self.for_large_orders(sales_amount, added_credit) # the has_large_purchases scope now takes two arguments preferred_customers = Customer.has_large_purchases(sales_amount, LOOK_BACK_PERIOD) preferred_customers.each do |customer| customer.add_credit added_credit end end end
I execute the spec and everything passes:
.
Finished in 0.00063 seconds
1 example, 0 failures
Wow! That's quite a bit of change and nothing failed. Yet.
Let's make sure that only those messages are stubbed that have the correct arguments. I add the with() method to the stub's method chain:
describe GivesCreditToPreferredCustomers do specify "for large orders" do sales_amount = 10000 credit_given = 100 look_back_period = 3 found_customer = Customer.new Customer.stub(:has_large_purchases) \ # stub with arguments .with(sales_amount, look_back_period) \ .and_return [found_customer] GivesCreditToPreferredCustomers \ .for_large_orders(sales_amount, credit_given) found_customer.total_credit.should == credit_given end end
Everything passes in the spec but we are now stubbing messages only where the :has_large_purchases method is called with the passed in sales amount (10,000) and the correct look back period (3).
.
Finished in 0.00062 seconds
1 example, 0 failures
Let's see what happens when the LOOK_BACK_PERIOD is changed to 2 due to a new requirement from the customer:
F
Failures:
1) GivesCreditToPreferredCustomers for large orders
Failure/Error: preferred_customers = Customer.has_large_purchases(sales_amount, LOOK_BACK_PERIOD)
expected: (10000, 3)
got: (10000, 2)
# ./describe_stub_spec.rb:38:in `block (2 levels) in
Finished in 0.00104 seconds
1 example, 1 failure
This would happily pass with a stub where I don't specify the arguments but it fails here where the stub argument is strictly defined.
Adding the argument is a little bit more work but the benefits are huge: you are exercising not only the message sent to the object but the arguments that the message is sent with.
Happy stubbing!
You can review the example I created for this blog post in this Gist.