We lower the level of connectivity using DI to improve code testability, an example implementation
In the beginning of the article I want to immediately notice that I do not pretend to be new, but just want to share / recall such a possibility as IoC DI.
Also, I have almost no experience writing articles, this is my first. I tried as best I could, if you do not judge strictly.
What is it all about
Most of the Rails projects I've come across have one big problem. They either do not have tests at all, or their tests check some insignificant part, and the quality of these tests leaves much to be desired.
The main reason for this is that the developer simply does not know how to write code so that in unit tests only test the code written by him, and not test code that, say, is contained in some other service object or library.
Then a logical chain is formed in the programmer’s head, and why should I even carry the business logic code to another layer, I’ll add just a couple of lines and everything will meet the requirements of the customer.
And this is very bad, because unit testing is losing resistance to refactoring, and managing changes in such code becomes difficult. Entropy is gradually increasing. And if you're already afraid of refactoring your code, things are very bad.
To solve such problems in the Java world, there have long been a number of libraries and there is not much point in reinventing the wheel, although it should be noted that these solutions are very cumbersome and there is not always a reason to use them. Rubisty apparently somehow solve similar problems, but I honestly did not understand how. By this, I decided to share how I decided to do it.
The general idea how to solve this in ruby projects
The basic idea is that for objects with dependencies, we must be able to manage them.
Consider an example:
classUserServicedefinitialize()
@notification_service = NotificationService.new
enddefblock_user(user)
user.block!
@notification_service.send(user, 'you have been blocked')
endend
To test the block_user method, we get into an unpleasant moment, because y will be triggered by a notify from the NotificationService and we are forced to process some minimal part that this method executes.
Inversion allows us to just get out of this situation if we implement the UserService, for example, like this:
classUserServicedefinitialize(notification_service = NotificationService.new)
@notification_service = notification_service
enddefblock_user(user)
user.block!
@notification_service.send(user, 'you have been blocked')
endend
Now when testing, we serve as a NotificationService mock object, and check that the block_user is jerking the notification_service methods in the correct order and with the right arguments.
RSpec.describe UserService, type::servicedo
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)
endendend
Specific example for rails
When there are a lot of service objects in the system, it becomes difficult to construct all the dependencies yourself, the code begins to overgrow with unnecessary lines of code that reduce readability.
In this regard, it occurred to me to write a small module that automates dependency management.
moduleServicesmoduleInjectordefself.included(base)# TODO: check base, should be controller or service
base.extend ClassMethods
endmoduleClassMethodsdefinject_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)
endendendendmoduleHelpersclassServicedefinitialize(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)"endenddefto_s"#{@name}_service"enddefinstanceif Rails.env.test?
ifdefined? RSpec::Mocks::ExampleMethods
extend RSpec::Mocks::ExampleMethods
instance_double @class
elsenilendelse
@class.instance
endendendendend
There is one nuance, the service should be Singleton, i.e. have an instance method. The easiest way to do this is to write include Singleton
in the service class.
Now in ApplicationController add
require'services'classApplicationController < ActionController::Baseinclude Services::Injector
end
And now in the controllers we can do so
classWelcomeController < ApplicationController
inject_service :welcomedefindex
render plain: welcome_service.text
endend
In the spec of this controller, we automatically get an instance_double (WelcomeService) as a dependency.
RSpec.describe WelcomeController, type::controllerdo
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 200endendend
What can be improved
Imagine, for example, that in our system there are several options for how we can send notifications, for example, at night it will be one provider, and in the afternoon another. At the same time, providers have completely different sending protocols.
In general, the NotificationService interface remains the same, but there are two specific implementations.
classNightlyNotificationService < NotificationService endclassDailyNotificationService < NotificationService end
Now we can write a class that will perform conditional mapping services
classNotificationServiceMapperinclude Singleton
deftake
now = Time.now
((now.hour >= 00) and (now.hour <= 8)) ? NightlyNotificationService : DailyNotificationService
endend
Now when we take the service instance in Services :: Helpers :: Service.instance, we need to check if there is a * Mapper object, and if there is, then take a class constant through take.