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.