Simple Command
💡

Simple Command

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.

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? i failure?
  • jeśli operacja się powiedzie, to wartość zwracana przez metodę call będzie dostępna pod atrybutem result
  • 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.