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:

    @notification_service = NotificationService.new
    @notification_service.send(user, 'you have been blocked')

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
    @notification_service.send(user, 'you have been blocked')

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")

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
        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)
        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

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

And now in the controllers we can do so

classWelcomeController < ApplicationController
  inject_service :welcomedefindex
    render plain: welcome_service.text

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
    now = Time.now
    ((now.hour >= 00) and (now.hour <= 8)) ? NightlyNotificationService : DailyNotificationService

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: