Nikita Kharitonov
3 min readJun 17, 2021

--

Rspec — Use mocks in the proper way

Setup

Nobody likes when the controller contains too much business logic and becomes fat. Let's start with one simple controller:

class ReservationsController < ActionController::Base
def create
book = Book.available.find_by(isbn: params[:isbn])

if book
BookReservation.create(user: current_user)
book.update(available_count: book.available_count--)

render :success
else
render :not_found
end
end
end

We have an easy solution to this problem. Extract business logic into a separate Service object and call it from the controller. Before starting the refactoring we should write specs. Always check for specs before the refactoring! Fortunately, we already have them

RSpec.describe ReservationsController, type: :controller do
describe 'POST create' do
subject { post :create, params: { isbn: book.isbn } }
context 'when book is not available' do
let(:book) { create(:book, available_count: 0) }
it { is_expected.to render :not_found }
end
context 'when book is not available' do
let(:book) { create(:book, available_count: 1) }
it { is_expected.to render :success}
end
end
end

Refactoring

Ok, we moved business logic into a separate class and added specs for this class. Our controller becomes much slimmer and performs only rendering work.

class ReservationsController < ActionController::Base
def create
ReserveBook.new(params[:isbn]).call
render :success
rescue ReserveBook::Failed
render :not_found
end
end

Our controller was not touched and creates the whole universe to perform tests. Moreover, the same context is created in specs for the controller and service object. There are several options for how we can handle it. The first option, we can extract code into shared_context and use it in both cases. However, didn’t I say that the controller should perform only rendering 😞

Holy Grail?

The second option, here when mocks enter the stage.

RSpec.describe ReservationsController, type: :controller do
describe 'POST create' do
subject { post :create, params: { isbn: 'isbn' } }
context 'when service returns failure' do
before do
allow_any_instance_of(
ReserveBook::Failed
).to receive(:call).and_raise(ReserveBook::Failed)
end
it { is_expected.to render :not_found }
end
context 'when service returns success' do
before do
expect_any_instance_of(
ReserveBook
).to receive(:call).and_return(:ok)
end
it { is_expected.to render :success}
end
end
end

We decouple from the service logic. It is a black box for us, so I don’t want to care about the correct setup of the context. However, there are still several issues. The spec above might pass or not. 😆 Why? It still calls real new that might need the real context.

...
allow(ReserveBook::Failed).to receive(:new).and_return(service)
let(:service) { double }
context 'when service returns failure' do
before do
allow(double).to receive(:call).and_raise(ReserveBook::Failed)
end
it { is_expected.to render :not_found }
end
...

Punchline

But what if someone decides to add some parameters for call? Spec still will pass. That’s why rspec introduced verified mocks instance_double and class_double. They work the same as regular `double`, but also check whether a called message exists and accepts the same list of parameters. We will use both this double, cause not only call notations might be changed but new also.

...
let(:service_class) { class_double(ReserveBook, new: service) }
let(:service) { instance_double(ReserveBook) }
context 'when service returns failure' do
before do
allow(double).to receive(:call).and_raise(ReserveBook::Failed)
end
it { is_expected.to render :not_found }
end
...

Conclusion

We removed business logic from the controller, didn’t repeat yourselves in spec, protected specs from notation changes of the extracted service object.

Worth mentioning that mocks should be used with caution because we are in Ruby and verified double do not protect us from changing the order of the parameters.

--

--