Понижаем уровень связанности с помощью DI для улучшения тестируемости кода, пример реализации

В началье статьи хочу сразу заметить, что я не претендую на новизну, а только хочу поделиться/напомнить о такой возможности как 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.

FavoriteLoadingДобавить в избранное
Posted in Без рубрики

Добавить комментарий

Ваш e-mail не будет опубликован. Обязательные поля помечены *