RSpec mocks let you control the behavior of classes to make it easier to test a class that uses those other classes (called dependences)
Suppose we have this routine:
class Purchase
def purchase(customer, product)
payments = Payments.new
order = Order.create!(customer: customer, product: product)
result = payments.charge(customer.payment_method, order.cost)
if result.declined?
DeclineMailer.email(order)
else
FulfillOrder.new(order).fulfill!
end
end
end
Don't worry about how great or not this code is. Sometimes things are complicated.
Testing this is gonna be rough because Payments
charges credit cards, DeclineMailer
sends email and who knows what FulfillOrder
does?
But we need to check that we made an order and handled the decline properly to know if purchase
is working as intended.
We can use rspec to mock the behavior of our complicated dependencies Payments
, DeclineMailer
, and FulfillOrder
.
First, let's handle the case of successfully charging. We want to check that our order was created and that FullfillOrder
's fulfill!
method is called.
To make sure that happens, we need a verison of Payments
whose charge
method returns a result representing a successful charge.
RSpec.describe Purchase do
subject(:purchase) { described_class.new }
describe "#purchase" do
context "card charges successfully" do
it "fulfills the order" do
customer = create(:customer)
product = create(:product)
# TODO: Configure Payments#charge to return success
purchase.purchase(customer,product)
expect(customer.orders.size).to eq(1)
expect(customer.orders.product).to eq(product)
# TODO: assert that FullfillOrder#fulfill! was called
end
end
end
end
Using RSpec's mocking library, we handle both our TODOs in largerly the same way:
- we mock the constructor of each class to return a mock object so when
Purchase
callsnew
on them, it gets our mock - we configure that mock object to allow certain methods to be called
- we check the mock objects to see what happened ( at least for those that we care about )
We'll use instance_double
to create the mock object. This makes a mock that won't respond to methods the underlying class won't respond
to. We'll then use allow
to configure what happens when .new
is called. allow
will also be used to configure what happens with we call charge
on our Payments
mock:
[ ed note - if I were writing a book, I would do this in smaller steps ]
RSpec.describe Purchase do
subject(:purchase) { described_class.new }
describe "#purchase" do
context "card charges successfully" do
it "fulfills the order" do
customer = create(:customer)
product = create(:product)
➡ payments = instance_double(Payments)
➡ fulfill_order = instance_double(FullfillOrder)
➡ allow(Payments).to receive(:new).and_return(payments)
➡ allow(payments).to receive(:charge).and_return(OpenStruct.new(:declined?: false))
➡ allow(FullfillOrder).to receive(:new).and_return(fulfill_order)
➡ allow(fulfill_order).to receive(:fulfill_order)
purchase.purchase(customer,product)
expect(customer.orders.size).to eq(1)
order = customer.orders.first
expect(customer.orders.product).to eq(product)
# TODO: assert that FullfillOrder#fulfill! was called
end
end
end
end
Now, when purchase.purchase
is called, when charge
is called, our mock payments
will respond and return an object that responds
false
to declined?
That will call fulfill_order
on our FullfillOrder
mock. We just need to make sure that happened, as that is the point of this test.
We do that by expect
ing the mock to have_received
the method call:
RSpec.describe Purchase do
subject(:purchase) { described_class.new }
describe "#purchase" do
context "card charges successfully" do
it "fulfills the order" do
customer = create(:customer)
product = create(:product)
payments = instance_double(Payments)
fulfill_order = instance_double(FullfillOrder)
allow(Payments).to receive(:new).and_return(payments)
allow(payments).to receive(:charge).and_return(OpenStruct.new(:declined?: false))
allow(FullfillOrder).to receive(:new).and_return(fulfill_order)
allow(fulfill_order).to receive(:fulfill_order)
purchase.purchase(customer,product)
expect(customer.orders.size).to eq(1)
order = customer.orders.first
expect(order.product).to eq(product)
➡ allow(fulfill_order).to have_received(:fulfill_order).with(order)
end
end
end
end
Awesome! To check the other case, we change and_return(OpenStruct...
to return :declined? true
and then assert that our mailer was called.
This is great!
I'd double down in use a rspec's double instead of
OpenStruct
to remark to the maintainers that is not the real object but an rspec artifact - of course, there's nothing wrong on use one or the other.