Kto kiedykolwiek pracował w projekcie railsowym napewno widział tzw. fat controllers, gdzie logika poszczególnych akcji wpakowana jest bezpośrednio w kontrolery, które automatycznie mają setki metod prywatnych i tysiące linii kodu, a akcje wyglądają np. tak:
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
Mam taką dobrą praktykę, że przenoszę wszelką logikę z kontrolerów do zewnętrznych domenowych klas serwisów, komend, use case’ów – zwał jak zwał. Testując takie kontrolery, obchodzi mnie tylko kształt odpowiedzi, które zwracają poszczególne akcje oraz to, czy wywoływany został odpowiedni serwis z odpowiednimi parametrami. To wszystko.
Aby zastosować takie rozwiązanie wystarczą PORO (Plain Old Ruby Object), natomiast ja lubię stosować małą, przyjemną bibliotekę Simple Command, która rozszerza nam nasze PORO o kilka rzeczy poprawiających wygodę i testowanie takich klas.
Link do libki: https://github.com/nebulab/simple_command
Z dokumentacji gema możemy wyczytać, że:
- aby wykorzystać
SimpleCommand
w swojej klasie należy (tu mam trochę problem z odpowiednim przetłumaczeniem tego na polski), poprzedzić ją właśnie klasąSimpleCommand
prepend SimpleCommand
Jeśli chcesz wiedzieć, o co chodzi z prepend
zajrzyj tutaj.
- będziemy mogli wywołać naszą klasę poprzez zdefiniowaną w niej metodę
.call
- nasza klasa będzie miała dwa atrybuty mówiące o powodzeniu operacji:
success?
ifailure?
- jeśli operacja się powiedzie, to wartość zwracana przez metodę
call
będzie dostępna pod atrybutemresult
- jeśli operacja się nie powiedzie, to błędy z tej operacji możemy odczytać z atrybutu
errors
Testowanie takiej klasy jest bardzo proste. Testujemy tak naprawdę tylko 3 przypadki:
- czy przy podaniu poprawnych parametrów wywołanie zakończy się sukcesem
- czy przy podaniu niepoprawnych parametrów otrzymamy błąd
- czy przy pozytywnym zakończeniu operacji komenda zwróci oczekiwany rezultat
Oczywiście, możemy iść głębiej i sprawdzać, czy wewnątrz komendy zostały wywołane jakieś operacje na innych klasach albo czy tablica errors
zawiera informacje o konkretnych błędach lub też, czy komenda rzuca wyjątki odpowiednie dla naszych przypadków testowych. Natomiast te trzy powyższe punkty wystarczają, by czuć się komfortowo z implementacją logiki zawartej w komendzie.
Przejdźmy teraz do konkretnego przypadku logowania użytkownika do API. Kod, który przygotowałem jest framework agnostic, to pokazuje, że SimpleCommand i wzorzec komendy, który wspiera ta biblioteka, można zastosować także poza Ruby on Rails, w skryptach, aplikacjach konsolowych, automatyzacjach itd.
Klasyczny przykład wg. rails waya:
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
Metoda login
jest dość długa. Jest parę ifów, jakieś złożone warunki, kilka renderów. Co, jeśli do aplikacji dodamy np. 2 Factor Authentication? Trzeba będzie tę logikę zawrzeć gdzieś między tymi ifami. Wydzielanie tego kodu do metod prywatnych nie do końca zda egzamin. Co, jeśli inna akcja będzie korzystać z tej samej metody, a w międzyczasie zmodyfikujemy metodę do nowych wymagań?
Przenieśmy całą logikę do komendy LoginUser
. Na potrzeby przykładu klasy User
i AuthToken
są zaślepkami, ich implementacja nas nie interesuje.
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
Co tu mamy? Komenda przyjmuje parametry potrzebne do zalogowania. W trakcie egzekwowania logiki rzuca własne wyjątki, łapie te wyjątki i ustawia adekwatnie do nich kody i odpowiedzi błędów. Jest kilka metod prywatnych, wydzielonych dla większej przejrzystości kodu.
Teraz zobaczmy na test takiej komendy:
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
Testy są bardzo proste. Najpierw sprawdzamy happy path, a potem to, jak komenda zachowuje się w przypadku niepoprawnych danych logowania. Tylko tyle i aż tyle.
A tak będzie wyglądało wywołanie komendy w kontrolerze i test dla akcji logowania:
require_relative './command_helpers'
class AuthControllerinclude 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
A tutaj 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
Prosty scenariusz. Na poziomie kontrolera sprawdzamy tylko poprawność wywołania komendy z odpowiednimi parametrami. A odpowiedzialność za test logiki i jej poprawność jest wydelegowana do innej warstwy.
No, ale co teraz? W momencie, kiedy będziemy mieć więcej takich akcji, które korzystają z komend, czeka nas niezła ifologia. Możemy sobie to ogarnąć przez pomocnicze metody. Na potrzeby przykładu wrzuciłem je do includowanego modułu CommandHelpers. Komenda jest przechwytywana przez handle i on już odpowiada za prawidłowe obsłużenie jej odpowiedzi lub błędów. Zawsze ten handler można bardziej rozbudować i dostosować. Przedstawiam Ci natomiast minimalną wersję implementacji.
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
Potem w kontrolerze wystarczy coś takiego:
def login_with_handler
handle LoginUser.call(auth_params)
end
I już 😀
W efekcie mamy enkapsulację logiki w przyjemnej klasie zawierającej dodatkowo informacje o powodzeniu lub przerwaniu akcji oraz czyste i przejrzyste kontrolery.
Uwaga! W repozytorium SimpleCommand na Githubie może Ci się rzucić w oczy, że nie ma tam żadnych nowych commitów od kilku lat. Biblioteka wygląda na nieutrzymywaną, a więc możesz mieć obawy przed jej użyciem. Nie martw się. Wystarczy zajrzeć w kod gema i zobaczyć, że cały SimpleCommand to tak naprawdę czyste PORO bez żadnych zależności. Równie dobrze zamiast zainstalowania gemu możemy sobie ten kod zawrzeć w jakiejś pomocniczej klasie w aplikacji railsowej.