Hi Myron,

Thanks for your reply -- yes that's helpful!

Essentially your local variable `sequence` is a poor man's 
`@messages_received`.   I hadn't thought of tracking that locally within 
the spec itself, but that definitely is the key to solving my problem 
without enhancing the existing API, albeit not as succinctly.  For anyone 
who stumbles on this in the future, I've included my new implementation of 
testing `fetch_products` at the very bottom of this post.

As for enhancing the API, one of your concerns is that the need to test 
blocks like this is too rare.

That's definitely your call, but I'll at least explain my line of thinking 
for you to consider, if you wish. Sorry, this is a long post...

I presume you'll agree that code within blocks itself is *not* rare, so one 
way or the other we need to test it   This really becomes a question of 
whether it's better to test them in isolation or better to test them in 
integration.

I want to compare this with a concrete example:

Suppose I have several commands that need to occur in a transaction:

def handle_real_estate_deal(buyer, seller, realtor1, realtor2, amount)
   Transaction.run do
     commission = amount * 0.06
     buyer.withdraw(amount)
     seller.deposit(amount - commission)
     realtor1.deposit(commission / 2)
     realtor2.deposit(commission / 2)
   end
 end



Now forgetting the transaction for a moment, an isolated test might look 
like this:

it "calls collaborators with correct amounts" do
   expect(buyer).to receive(:withdraw).with(100_000)
   expect(seller).to receive(:deposit).with(94_000)
   expect(realtor1).to receive(:deposit).with(3_000)
   expect(realtor2).to receive(:despoit).with(3_000)
   handle_real_estate_deal(buyer, seller, realtor1, realtor2, 100_000)
 end



However, there is a lot of subtle (but important) behavior that this method 
gets "for free" by running inside `Transaction.run`.

1. if the first command fails, nothing is committed
2. if the second command fails, nothing is committed
3. if the third command fails, nothing is committed
4. if the fourth command fails, nothing is committed

So I could write a series of small integration specs (integrating with 
`Transaction.run`, but mocking the other collaborators) to ensure that 
nothing is committed when various failures occur

Here's the first:

it "if the buyer withdraw fails, nothing is committed" do
   allow(buyer).to receive(:withdraw).and_raise
   allow(seller).to receive(:deposit)
   allow(realtor1).to receive(:deposit)
   allow(realtor2).to receive(:desposit)
 
   handle_real_estate_deal(buyer, seller, realtor1, realtor2, amount)
 
   expect(Transaction.commits).to be_empty
 end



Here's the second:

it "if the seller deposit fails, nothing is committed" do
   allow(buyer).to receive(:withdraw)
   allow(seller).to receive(:deposit).and_raise
   allow(realtor1).to receive(:deposit)
   allow(realtor2).to receive(:desposit)
 
   handle_real_estate_deal(buyer, seller, realtor1, realtor2, amount)
 
   expect(Transaction.commits).to be_empty
 end



The other specs would be similar, so I won't actually write them out here.  
Of course you might be able to use metaprogramming to make this a little 
easier


These integrated tests are really testing behavior that comes from 
`Transaction.run`, which presumably is well-tested somewhere else.

If instead I write an isolated test that verifies the commands are being 
called from within a `Transaction.run` block, I get essentially the same 
coverage by modifying my original isolated test to look like this:

it "calls collaborators with correct amounts from within a transaction 
block" do
  transaction = allow(Transaction).to receive(:run).and_yield

  expect(buyer).to receive(:withdraw).with(100_000).inside(transaction)
  expect(seller).to receive(:deposit).with(94_000).inside(transaction)
  expect(realtor1).to receive(:deposit).with(3_000).inside(transaction)
  expect(realtor2).to receive(:deposit).with(3_000).inside(transaction)
  handle_real_estate_deal(buyer, seller, realtor1, realtor2, 100_000)
end

As long as `Transaction.run` does what it's supposed to do, and as long as 
my boundaries are good, a few modifications to an existing test provides 
the same coverage as adding 4 integrated tests.  This is exacerbated 
further if `Transaction.run` has additional behavior beyond just preventing 
commits.

So I suppose I just don't fully understand why integrated tests are more 
ideal than isolated tests for something like this.  Maybe if we made it 
easier to test this way, it wouldn't be so rare?  For highly-critical 
pieces of your application, perhaps you want a full-set of integrated tests 
anyway, but for less-critical pieces, being able to get fairly thorough 
coverage without much boilerplate is a big win, isn't it?


As for passing one stub into another: I agree it's odd to pass one stub 
into another. I had considered just using the method name (i.e. 
`:unscoped`) itself as the identifier (e.g. `allow(Product).to 
receive(:all).inside(:unscoped)`), but it's possible to have more than one 
stub for `:unscoped`, possibly with different arguments, so I needed a way 
to uniquely identify that I was inside the correct block. Alternatively the 
user could name the block himself (i.e. something like `allow(Product).to 
receive(:unscoped).and_yield_as(:some_user_chosen_block_name)`, but that 
doesn't necessarily seem preferable.


Finally, for sake of anyone stumbling on this later, here is one way to 
stub a method to return different values depending on whether it is called 
from within a given block or not, piggybacking on Myron's suggestion to use 
a local variable in the spec itself to track messages (i.e. like he did 
with `sequence`)


def fetch_products
  visible_products = Product.all
  all_products = Product.unscoped do
    Product.all
  end
  { visible_products: visible_products, all_products: all_products }
end

it "fetches products" do
   all_products = double("all products")
   visible_products = double("visible products")
 
   block_stack = []
 
   allow(Product).to receive(:unscoped) do |&block|
     block_stack.push(:unscoped)
     result = block.call
     block_stack.pop
     result
   end
 
   allow(Product).to receive(:all) do
     if block_stack.include?(:unscoped)
       all_products
     else
       visible_products
     end
   end
 
   expect(fetch_products).to eq({
     visible_products: visible_products,
     all_products: all_products
   })
 
 end




Thank you for your help Myron!,


Nathan

-- 
You received this message because you are subscribed to the Google Groups 
"rspec" group.
To unsubscribe from this group and stop receiving emails from it, send an email 
to [email protected].
To post to this group, send email to [email protected].
To view this discussion on the web visit 
https://groups.google.com/d/msgid/rspec/51ccf0e9-2002-4f44-bf08-49e41d704d33%40googlegroups.com.
For more options, visit https://groups.google.com/d/optout.

Reply via email to