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`)