Понижаем уровень связанности с помощью 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.

