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.

Creating a Retro Game Over Behavior in Unity

What is Long Polling — Learn The Basics and Fundamentals

Running Spark on Kubernetes: a fully functional example and why it makes sense for OLX

Leetcode 525. Contiguous Array

How Much Does It Cost to Build an App in 2020?

The Seven Wonders of MITH Cash V2: Day 2 — Time-Weighted Boardroom Rewards, and Single Asset MIS…

How to backup your Github & Gitlab projects

MongoDB — Generating workloads using YCSB

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

Nullish coalescing operator (??)

Setting up Emacs for Clojure Programming Within Seconds

Rucy: A Ruby-to-BPF Compiler

Building Sinatra API Back-end with Active Record/Ruby Part 3