Mocking with Interdependency in RSpec
TweetI’ve used RSpec for a while for structuring and testing code, and although it has its critics, I find the flexibility of its DSL to be really useful.
The other day I encountered a situation in a controller test that I hadn’t dealt with before, where that flexibility came in pretty handy. Here’s the action I was testing:
def create
if current_order.process_payment! && current_order.next!
cookies.delete(:guest_token)
redirect_to current_order_path
else
render text: rendered_view
end
end
This is the confirmation step in a cart flow, where a payment is processed and, if successful, the user is redirected to the complete page. The code was working fine but the test was failing.
Here’s the original test code:
describe "POST create" do
before do
expect(order).to receive(:process_payment!).once.and_return(paid?)
end
context "successful capture" do
let(:paid?) { true }
before do
allow(order).to receive(:paid?).and_return(true)
post :create
end
it "redirects to cart complete page" do
expect(response).to redirect_to cart_complete_url }
end
end
...
end
So we’re setting an expectation on process_payment!
, which we want to check is called in all cases without exception. We are returning true
here because we want to see what happens when payment is successful. When it is successful, order.next!
is called, advancing the cart to the next step. If that returns true
we redirect to the order complete page (if not then render the confirm page again with an error).
The problem here is that the order state transition logic depends on the payment state: an order that is paid cannot go (back) to the “confirm” state. A before_action
in each cart controller step checks the value of paid?
and redirects accordingly.
So you’re in a Catch-22, because paid?
is being called twice, and should return a different value in each case. If you stub paid?
to return true
, but do not actually change the payment state, current_order.next!
fails. If you set it to return true
, then the order has already been paid and the controller jumps to the complete page without ever processing the payment, so the expectation fails (as it should).
What we want is for the mock to return different values the first and second time it is called. RSpec can actually do this with consecutive return values, so e.g. you could this:
allow(order).to receive(:paid?).and_return(false, true)
and the test would pass. The thing is, it would pass regardless of whether process_payment!
had been called before or after the second paid?
was called, so you could get a passing test when in reality the controller action would fail to work correctly.
What we really want here is for paid?
to return true
if and only if process_payment!
has been called. Surprisingly (or maybe not), that’s trivially easy to do by using RSpec’s block implementation to set an instance variable:
before do
@paid = false
expect(order).to receive(:process_payment!).once do
@paid = true
end
allow(order).to receive(:paid?) { @paid }
post :create
end
And now, the test passes and actually makes sense: we are stubbing out the payment logic behind paid?
and replacing it by a minimal implementation which returns true
once process_payment!
has been called on the order. This is really just a special use case of RSpec’s block implementation, but I found it surprisingly simple and easy to use.