Rspec — Use mocks in the proper way

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

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 😞

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
...

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
...

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.

Love podcasts or audiobooks? Learn on the go with our new app.

Using Route53 for Optimal Failover Efficiency & User Experience

How to Build Java Applications Today: August 16, 2021

How To Enable Cross-Continental Collaboration for Tech Teams

Corporate Video Production in Ragmere | ShowReel #Corporate #Video #Production #Ragmere https://t.co

Sorting Algorithms

Demystifying Spring Boot

Spring Boot programming language logo

Top 3 Things About Python I Wish I Knew Earlier

Get the Medium app

A button that says 'Download on the App Store', and if clicked it will lead you to the iOS App store
A button that says 'Get it on, Google Play', and if clicked it will lead you to the Google Play store
Nikita Kharitonov

Nikita Kharitonov

More from Medium

Ruby Controllers using Sinatra

Debugging a Rails application that is running in a docker container

Learning Management System Project with Source Code -Ruby

Deploying Rails 6 application on Ubuntu using Capistrano, Puma and Nginx