Who ever worked in a rails project must have seen the so-called fat controllers, where the logic of individual actions is packed directly into controllers that automatically have hundreds of private methods and thousands of lines of code, and the actions look like this:
def update
@template.update_attributes(page_template_params)
page_levels_ids = @template.page_levels.map(&:id)
saved_levels_ids = if params[:page_levels_ids]
params[:page_levels_ids].map{|id| id.to_i }
else
[]
end
ids_to_save = saved_levels_ids - page_levels_ids
ids_to_remove = page_levels_ids - saved_levels_ids
ids_to_remove.each { |id| PageLevel.find(id).update(page_template_id: nil) }
ids_to_save.each { |id| PageLevel.find(id).update(page_template_id: @template.id) }
@template.reload
PageProcessor::TemplateService.update_template(@template)
redirect_to edit_page_template_path(@template)
end
I have such good practice that I transfer all logic from controllers to external domain classes of services, commands, use cases - whatever you call it. When testing such controllers, I only care about the shape of the responses that return individual actions and whether the appropriate website was called with the appropriate parameters. That's all.
To use such a solution, PORO (Plain Old Ruby Object) is enough, while I like to use a small, nice library called Simple Command, which extends our PORO with a few things that improve the convenience and testing of such classes.
Link to library
From the documentation of the gem, we can read that:
-
to use
SimpleCommand
in your class you should (here I have a bit of a problem with the proper translation of it into Polish), precede it with theSimpleCommand
class
prepend SimpleCommand
If you want to know what prepend
is all about, check here.
we will be able to call our class through the
.call
method defined in itour class will have two attributes that indicate the success of the operation:
success?
andfailure?
if the operation is successful, the value returned by the
call
method will be available under theresult
attributeif the operation fails, the errors from this operation can be read from the
errors
attribute
Testing such a class is very simple. We really only test 3 cases:
if the call will be successful if the correct parameters are given
if we get an error when entering incorrect parameters
whether the command will return the expected result on a positive end of the operation
Of course, we can dive in and see if there are any operations on other classes inside the command, or if array errors
contain information about specific errors, or if the command throws exceptions suitable for our test cases. However, these three points above are enough to feel comfortable with the implementation of the logic contained in the command.
Now let's move on to the specific case of user login to the API. The code that I prepared is the agnostic framework, it shows that SimpleCommand and the command pattern it supports can also be used outside of Ruby on Rails, in scripts, console applications, automations, etc.
Classic example according to rails way:
require_relative './auth_token'
class BadAuthController < ApplicationController
def login
if email.nil? || email.length.zero?
render json: { error: 'Email not valid' }, status: 422
end
if user && user.authenticate(auth_params[:password])
render json: { user: user, auth_token: AuthToken.encode({ user_id: user.id }) }, status: :ok
elsif user.blank?
render json: { error: 'User not found' }, status: 404
elsif !user.authenticate(auth_params[:password])
render json: { error: 'Invalid password' }, status: 401
end
end
private
def user
@user ||= User.find_by(email: auth_params[:email])
end
def auth_params
params.require(:auth).permit(:email, :password)
end
end
The login
method is quite long. There are some ifs, some complex conditions, some renders. What if we add, for example, 2 Factor Authentication to the application? This logic will have to be tied down somewhere between the ifs. Distributing this code to private methods won't quite work. What if another action uses the same method and in the meantime, we modify the method to meet the new requirements?
Let's move all the logic to the LoginUser
command. For the purposes of the example, the User
and AuthToken
classes are plugs, we are not interested in their implementation.
require 'simple_command'
require_relative './user'
require_relative './auth_token'
class LoginUser
prepend SimpleCommand
class InvalidEmail < StandardError; end
class InvalidPassword < StandardError; end
class UserNotFound < StandardError; end
def initialize(auth_params)@email = auth_params[:email]
@password = auth_params[:password]
end
def call
raise InvalidEmail unless email_valid?
raise UserNotFound if user.nil?
raise InvalidPassword if password.nil? || !user_authenticated?
logged_user_payload
rescue InvalidEmail
errors.add 422, 'Invalid email given'
rescue InvalidPassword
errors.add 422, 'Invalid password'
rescue UserNotFound
errors.add 422, 'User not found'
end
private
attr_reader :email, :password
def logged_user_payload
{
user: user,
auth_token: auth_token
}
end
def user_authenticated?
user.authenticate(password)
end
def user
@user ||= User.find_by(email: email)
end
def email_valid?
!email.nil? && email.length
end
def auth_token
AuthToken.encode({ user_id: user.id })
end
end
What do we have here? The command accepts the parameters needed to log in. As logic is enforced, it throws its own exceptions, catches those exceptions, and sets error codes and error responses accordingly. There are a few private methods dedicated to make the code clearer.
Now let's see a test of such a command:
require_relative '../lib/login_user'
require 'spec_helper'
describe LoginUser do
it 'is defined' do
expect(described_class).not_to be nil
end
describe 'logging' do
let(:auth_params) {
{
email: 'email@example.com',
password: 'password'
}
}
subject { described_class.call(auth_params) }
it 'succeeds' do
expect(subject).to be_success
end
it 'logs in user' do
expect(subject.result).to have_key :user
expect(subject.result).to have_key :auth_token
end
context 'when no email given' do
let(:auth_params) {
{
email: nil,
password: 'password'
}
}
it 'fails' do
expect(subject).to be_failure
end
it 'raises error' do
expect(subject.errors.size).to be > 0
expect(subject.errors.first[1]).to eq 'Invalid email given'
end
end
context 'when no password given' do
let(:auth_params) {
{
email: 'email@example.com',
password: nil
}
}
it 'fails' do
expect(subject).to be_failure
end
it 'raises error' do
expect(subject.errors.size).to be > 0
expect(subject.errors.first[1]).to eq 'Invalid password'
end
end
context 'when user not found' do
before do
allow(User).to receive(:find_by).and_return(nil)
end
it 'fails' do
expect(subject).to be_failure
end
it 'raises error' do
expect(subject.errors.size).to be > 0
expect(subject.errors.first[1]).to eq 'User not found'
end
end
context 'when invalid password' do
before do
allow_any_instance_of(User).to receive(:authenticate).and_return(false)
end
it 'fails' do
expect(subject).to be_failure
end
it 'raises error' do
expect(subject.errors.size).to be > 0
expect(subject.errors.first[1]).to eq 'Invalid password'
end
end
end
end
The tests are very simple. We check the happy path first, then check how the command behaves in the event of invalid credentials. Only this and so much.
And this is how invoking the command in the controller and testing for the login action will look like:
require_relative './command_helpers'
class AuthController
include CommandHelpers
def login
login = LoginUser.call(auth_params)
if login.success?
return { data: login.result, status: 200 }
else
return { errors: login.errors, status: 422 }
end
end
private
def auth_params
param.require(:auth).permit(:email, :password)
end
end
And here is the test:
require 'spec_helper'
require_relative '../lib/auth_controller'
require_relative '../lib/login_user'
describe AuthController do
it 'is defined' do
expect(described_class).not_to be nil
end
let(:auth_params) {
{
email: 'email@example.com',
password: 'password'
}
}
subject { described_class.new(auth_params) }
let(:login) { instance_double(LoginUser)}
before do
allow(LoginUser).to receive(:call).with(auth_params).and_return(login)
allow(login).to receive(:success?).and_return(true)
allow(login).to receive(:result).and_return(anything)
subject.login
end
it 'logs user via command' do
expect(LoginUser).to have_received(:call).with(auth_params).once
end
it 'checks for command status' do
expect(login).to have_received(:success?).once
end
it 'gets command result' do
expect(login).to have_received(:result).once
end
it 'returns command result' do
expect(subject.login[:data]).to eq login.result
end
context 'when login failed' do
before do
allow(login).to receive(:success?).and_return(false)
allow(login).to receive(:failure?).and_return(true)
allow(login).to receive(:errors).and_return(anything)
subject.login
end
it 'checks for failure' do
expect(login).to have_received(:failure?).once
end
it 'checks for errors' do
expect(login).to have_received(:errors).once
end
it 'returns command errors' do
expect(subject.login[:errors]).to eq login.errors
end
end
end
Simple scenario. At the controller level, we only check the correct execution of the command with the appropriate parameters. And the responsibility for the logic test and its correctness is delegated to another layer.
Well, what now? The moment we have more of these actions that use commands, we will have a good idea. We can grasp it through auxiliary methods. For the sake of example, I have thrown them into the included CommandHelpers module. The command is caught by handle, and it is already responsible for handling its responses or errors properly. This handler is always more expandable and customizable. However, I present to you the minimum version of the implementation.
module CommandHelpers
private
def handle(command)
command_result(command) do |result|
render json: result
end
end
def command_result(command)yield({ data: command.result }) if command.success?
yield({ errors: command.errors }) if command.failure?
end
end
Then something like this is enough in the controller:
def login_with_handler
handle LoginUser.call(auth_params)
end
And that’s it. 😃
As a result, we have the encapsulation of logic in a pleasant class that additionally contains information about the success or interruption of the action as well as clean and transparent controllers.
Attention! In the SimpleCommand repository on Github, you may notice that there have been no new commits there for several years. The library appears to be unmaintained, so you may be concerned about using it. Do not worry. Just look at the gem code and see that the entire SimpleCommand is really pure PORO without any dependencies. Equally, instead of installing the gem, we can include this code in some helper class in a rails application.
Top comments (0)