Refactoring Rails into Service Objects
One of the things that I dislike about Rails is how some actions feel untestable. Take, for example, the act of grabbing the current user.
# /app/controllers/application_controller.rb class ApplicationController < ActionController::Base protect_from_forgery with: :exception helper_method :current_user private def current_user @current_user ||= User.find(session[:user_id]) if session[:user_id] end end
This is bog-standard code that you'd see in a lot of Rails tutorials
and people's codebases. What I don't like about it is that the
current_user
method is not easily testable. Heck, which test
file do you put the tests in?
Another example is the login/logout functionality that exists in the same boilerplate way.
# /app/controllers/sessions_controller.rb class SessionsController < ApplicationController def new end def create user = User.where(:email => params[:email]).first if user and user.authenticate(params[:password]) session[:user_id] = user.id redirect_to root_url else session[:user_id] = nil render :new end end def destroy session[:user_id] = nil flash[:success] = I18n.translate('logout.success') redirect_to root_url end end
Fairly common eh? And yet, should the business logic of handling login and logout functionality be handled (and tested) in the controller? No, and this is a code smell.
At this point I like to create a service object that handles some common functionality found in all the above controllers, but with a nod to being easily testable and self-contained from a controller scope. Another added benefit is that the service object is typically devoid of any Rails framework calls so testing can happen quickly and without side effects because each dependency is injected into the object at runtime.
# /app/services/session_service.rb class SessionService def initialize (session, user_finder = User, session_key = :user_id) @session = session @user_finder = user_finder @session_key = session_key end def login (email, password) logout return false if email.nil? or email == '' return false if password.nil? or password == '' user = @user_finder.where(:email => email).first if user and user.authenticate(password) @session[@session_key] = user.id end return logged_in? end def logged_in? not @session[@session_key].nil? end def user return nil if not logged_in? @user_finder.find(@session[@session_key]) end def logout @session[@session_key] = nil end end
That's a lot of code, but each method is deceptively simple. I'm
using contructor injection to specify the session, the session key
and the object that knows how to find user records. Sensible
defaults are given so that callers don't need to specify everything
(this is one thing that Ruby does well versus, say, Java). The
session
and session_key
are used to look up the user ID
in the session.
I keep all service objects in a directory called app/services
so
that (a) they are loaded automatically by Rails and (b) they are
in a common place within the codebase. This is in contrast to putting code
in lib
, which I find to cause search nightmares. The corresponding
tests for each service object are held in test/services
.
To test the service object you will first need to tell Rake to
use the test/services
directory. Put the following code in
a file in the lib/tasks
directory and Rake will pick it
up (give the file a .rake
extension too).
# /lib/tasks/test_services_dir.rake namespace :test do desc "Test services classes" Rake::TestTask.new(:services) do |t| t.libs << "test" t.pattern = 'test/services/**/*_test.rb' end end service_task = Rake::Task['test:services'] test_task = Rake::Task[:test] test_task.enhance { service_task.invoke }
With that file in place, you can now do rake
or rake test:services
and your service objects will be tested.
So how does one test a service object? It's quite easy since the majority of the dependencies are injected. This allows you to use tools like mocha to mock out the method calls that you don't care about, freeing you from having to tie yourself into the Rails framework. Here is what the test file looks like.
# /test/services/session_service_test.rb require 'test_helper' class SessionServiceTest < ActiveSupport::TestCase setup do @user = users(:basic_user) # password: "password" @session = {} @session_key = 'id' @user_finder = mock() @service = SessionService.new(@session, @user_finder, @session_key) end teardown do session = nil user_finder = nil @service = nil end test 'login returns false if email nil' do assert_equal false, @service.login(nil, 'password') end test 'login returns false if password nil' do assert_equal false, @service.login('test@example.com', nil) end test 'login returns false if email blank' do assert_equal false, @service.login('','password') end test 'login returns false if password blank' do assert_equal false, @service.login('test@example.com', '') end test 'login returns false if email is incorrect' do @user_finder.expects(:where).returns([]) assert_equal false, @service.login('blah@example.com', 'password') end test 'login returns false if password is incorrect' do @user_finder.expects(:where).returns([@user]) User.any_instance.expects(:authenticate).returns(false) assert_equal false, @service.login('test@example.com', 'wrong') end test 'login returns true if email and password are correct' do @user_finder.expects(:where).returns([@user]) User.any_instance.expects(:authenticate).returns(true) assert_equal true, @service.login('test@example.com', 'password') end test 'logged_in returns false if session empty' do assert_nil @session[@session_key] assert_equal false, @service.logged_in? end test 'logged_in returns true if session contains id' do @session[@session_key] = 1 assert_equal true, @service.logged_in? end test 'user returns guest user when no session exists' do @session[@session_key] = nil assert_equal GuestUser, @service.user.class end test 'user returns valid user when session exists' do @session[@session_key] = 1 @user_finder.expects(:find).returns(@user) assert_equal @user, @service.user end test 'logout resets session' do @session[@session_key] = 1 @service.logout assert_nil @session[@session_key] end test 'login_as sets the session' do assert_nil @session[@session_key] @service.login_as(1) assert_equal 1, @session[@session_key] end end
Putting this service object to use allows us to simplify the
current_user
, login and logout functionality displayed
previously. It also allows me to be more confident that each
code branch will be tested properly.
# /app/controllers/application_controller.rb class ApplicationController < ActionController::Base # Prevent CSRF attacks by raising an exception. # For APIs, you may want to use :null_session instead. protect_from_forgery with: :exception helper_method :current_user private def current_user service = SessionService.new(session) @current_user ||= service.user end end
# /app/controllers/sessions_controller.rb class SessionsController < ApplicationController before_filter :load_session_service def new end def create if @session_service.login(params[:email], params[:password]) redirect_to dashboard_url else render :new end end def destroy @session_service.logout flash[:success] = I18n.translate('logout.success') redirect_to root_url end private def load_session_service @session_service = SessionService.new(session) end end
One last feature of using a service object such as SessionService
is that the
handling of the session object for holding user IDs (denoting whether
the user is logged in or out) is kept in one place. If you should ever
change session[:user_id]
to session[:admin_id]
without this
service object, you'd need to search through your codebase to find
all instances, whereas with a service object it is all held in one
place.