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 Singletonin 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.


Also popular now: