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