В началье статьи хочу сразу заметить, что я не претендую на новизну, а только хочу поделиться/напомнить о такой возможности как IoC DI.


Также у меня почти нет опыта написания статей, это моя первая. Я старался как мог, если что не судите строго.


О чем вообще речь


Большая часть проектов на Rails, с которыми я сталкивался, имеют одну большую проблему. Они либо не имеют тестов вовсе, либо их тесты проверяют какую-то незначительную часть, при этом качество этих тестов оставляет желать лучшего.


Основная причина этого заключается в том, что разработчик просто не знает как можно написать код так, что бы в unit-тестах тестировать только написанный им код, а не тестировать код, который, допустим, содержится в каком-то ином сервисном объекте или библиотеке.


Далее в голове программиста складывается логичная цепочка, а зачем мне вообще выносить код бизнес логики в другой слой, я же тут всего пару строк добавлю и все будет соответствовать требованиям заказчика.


И это очень плохо, потому что модульное тестирование теряет устойчивость к рефакторингу, управлять изменениями в таком коде становится тяжело. Энтропия постепенно растет. А если вы уже боитесь рефакторить свой код, дела очень плохи.


Для решения в подобных задач в мире Java уже давно существует ряд библиотек и нет особого смысла изобретать велосипед, хотя, надо заметить, что эти решения весьма громоздкие и не всегда есть причина их использовать. Рубисты видимо как-то иначе решают подобные задачи, но я, честно говоря, так и не понял как. По этому я решил поделиться как это решил сделать я.


Общая идея как решать это в ruby проектах


Основная идея заключается в том, что для объектов имеющими зависимости мы должны иметь возможность управлять ими.


Рассмотрим пример:


class UserService
  def initialize()
    @notification_service = NotificationService.new
  end

  def block_user(user)
    user.block!
    @notification_service.send(user, 'you have been blocked')
  end
end

Чтобы протестить метод block_user мы попадаем на неприятный момент, ведь y нас сработает notify из NotificationService и мы вынужденны обрабатывать какую-то минимальную часть, которую выполняет этот метод.
Инверсия позволяет нам просто выйти из такой ситуации если мы реализуем UserService, например, так:


class UserService
  def initialize(notification_service = NotificationService.new)
    @notification_service = notification_service
  end

  def block_user(user)
    user.block!
    @notification_service.send(user, 'you have been blocked')
  end
end

Теперь при тестировании мы подаем в качестве NotificationService mock объект, и проверяем, что block_user дергает методы notification_service в правильном порядке и с правильными аргументами.


RSpec.describe UserService, type: :service do
  let (:notification_service) { instance_double(NotificationService) }
  let (:service) { UserService.new(notification_service) }

  describe ".block_user" do
    let (:user) { instance_double(User) }

    it "should block user and send notification" do
      expect(user).to receive :block!
      expect(notification_service).to receive(:send).with(user, "you have been blocked")

      service.block_user(user)
    end
  end
end

Конкретный пример для rails


Когда сервисных объектов в системе становится много, конструировать самому все зависимости становится трудно, код начинает обрастать лишними строками кода, которые снижают читаемость.


В связи с этим пришла в голову написать небольшой модуль, который автоматизирует управление зависимостями.


module Services
  module Injector
    def self.included(base)
      # TODO: check base, should be controller or service
      base.extend ClassMethods
    end

    module ClassMethods
      def inject_service(name)
        service = Services::Helpers::Service.new(name)

        attr_writer service.to_s

        define_method service.to_s do
          instance_variable_get("@#{service.to_s}").tap { |s| return s if s }
          instance_variable_set("@#{service.to_s}", service.instance)
        end
      end
    end
  end

  module Helpers
    class Service
      def initialize(name)
        raise ArgumentError, 'name of service should be a Symbol' unless name.is_a? Symbol

        @name = name.to_s.downcase
        @class = "#{@name.camelcase}Service".constantize

        unless @class.respond_to? :instance
          raise ArgumentError, "#{@name.to_s} should be singleton (respond to instance method)"
        end
      end

      def to_s
        "#{@name}_service"
      end

      def instance
        if Rails.env.test?
          if defined? RSpec::Mocks::ExampleMethods
            extend RSpec::Mocks::ExampleMethods
            instance_double @class
          else
            nil
          end
        else
          @class.instance
        end
      end
    end
  end
end

Тут есть один нюанс, сервис должен быть Singleton, т.е. иметь метод instance. Проще всего сделать это написав include Singleton в сервисном классе.


Теперь в ApplicationController добавим


require 'services'

class ApplicationController < ActionController::Base
  include Services::Injector
end

И теперь в контроллерах можем делать так


class WelcomeController < ApplicationController
  inject_service :welcome

  def index
    render plain: welcome_service.text
  end
end

В спеке этого контроллера мы автоматом получим instance_double(WelcomeService) в качестве зависисомости.


RSpec.describe WelcomeController, type: :controller do
  describe "index" do
    it "should render text from test service" do
      allow(controller.welcome_service).to receive(:text).and_return "OK"

      get :index

      expect(response).to have_attributes body: "OK"
      expect(response).to have_http_status 200
    end
  end
end

Что можно улучшить


Представим, например, что в нашей системе есть несколько вариантов как мы можем отправлять уведомления, например ночью это будет один провайдер, а днем другой. При этом провайдеры имеют совершенно разные протоколы отправки.


В целом интерфейс NotificationService остается тем же, но есть две конкретные имплементации.


class NightlyNotificationService < NotificationService end
class DailyNotificationService < NotificationService end

Теперь мы можем написать класс, который будет выполнять условный маппинг сервисов


class NotificationServiceMapper
  include Singleton

  def take
    now = Time.now

    ((now.hour >= 00) and (now.hour <= 8)) ? NightlyNotificationService : DailyNotificationService
  end
end

Теперь когда мы берем инстанцию сервиса в Services::Helpers::Service.instance мы должны проверить есть ли *Mapper объект, и если есть, то взять константу класса через take.

Комментарии (0)