Diminuindo o nível de conectividade usando o DI para melhorar a testabilidade do código, exemplo de implementação

No começo do artigo, quero observar imediatamente que não pretendo ser novo, mas quero compartilhar / relembrar uma oportunidade como a IoC DI.


Além disso, quase não tenho experiência em escrever artigos, este é o meu primeiro. Eu tentei o meu melhor, se você não julga estritamente.


Do que estamos falando?


A maioria dos projetos Rails que encontrei tem um grande problema. Eles não têm testes, ou seus testes verificam parte insignificante, enquanto a qualidade desses testes deixa muito a desejar.


A principal razão para isso é que o desenvolvedor simplesmente não sabe escrever o código, de modo que, nos testes de unidade, teste apenas o código escrito por ele e não o código, que, por exemplo, está contido em outro objeto ou biblioteca de serviço.


Além disso, uma cadeia lógica é formada na cabeça do programador, mas por que eu deveria colocar o código de lógica de negócios em uma camada diferente, adicionarei apenas algumas linhas aqui e tudo atenderá aos requisitos do cliente.


E isso é muito ruim, porque o teste de unidade perde sua resistência à refatoração e o gerenciamento de alterações nesse código se torna difícil. A entropia está aumentando gradualmente. E se você já tem medo de refatorar seu código, as coisas estão muito ruins.


Para resolver esses problemas no mundo Java, existem várias bibliotecas há muito tempo e faz pouco sentido reinventar a roda, embora deva-se notar que essas soluções são muito volumosas e nem sempre há um motivo para usá-las. Os rubististas parecem resolver de algum modo esses problemas de uma maneira diferente, mas honestamente, eu ainda não entendi como. Portanto, decidi compartilhar como decidi fazê-lo.


A idéia geral de como resolver isso em projetos ruby


A idéia básica é que, para objetos que possuem dependências, devemos poder gerenciá-los.


Considere um exemplo:


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 

Para testar o método block_user, chegamos a um momento desagradável, porque a notificação do NotificationService funcionará para nós e somos forçados a processar uma parte mínima que esse método executa.
A inversão nos permite simplesmente sair dessa situação se implementarmos um UserService, por exemplo, assim:


 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 

Agora, ao testar, passamos um objeto como simulação NotificationService e verificamos se o block_user puxa os métodos notification_service na ordem correta e com os argumentos corretos.


 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 

Estudo de caso para trilhos


Quando há muitos objetos de serviço no sistema, torna-se difícil construir todas as dependências, o código começa a crescer com linhas extras de código que reduzem a legibilidade.


Nesse sentido, ocorreu-me escrever um pequeno módulo que automatiza o gerenciamento de dependências.


 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 

Há uma ressalva, o serviço deve ser Singleton, ou seja, tem um método de instância. A maneira mais fácil de fazer isso é escrevendo include Singleton na classe de serviço.


Agora, no ApplicationController, adicione


 require 'services' class ApplicationController < ActionController::Base include Services::Injector end 

E agora nos controladores podemos fazer isso


 class WelcomeController < ApplicationController inject_service :welcome def index render plain: welcome_service.text end end 

Nas especificações deste controlador, obtemos automaticamente instance_double (WelcomeService) como uma dependência.


 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 

O que pode ser melhorado


Imagine, por exemplo, que em nosso sistema existem várias opções de como podemos enviar notificações, por exemplo, à noite, será um provedor e outro durante o dia. Ao mesmo tempo, os provedores têm protocolos de envio completamente diferentes.


Em geral, a interface NotificationService permanece a mesma, mas há duas implementações específicas.


 class NightlyNotificationService < NotificationService end class DailyNotificationService < NotificationService end 

Agora podemos escrever uma classe que executará o mapeamento condicional dos serviços


 class NotificationServiceMapper include Singleton def take now = Time.now ((now.hour >= 00) and (now.hour <= 8)) ? NightlyNotificationService : DailyNotificationService end end 

Agora, quando pegamos a instância de serviço em Services :: Helpers :: Service.instance, precisamos verificar se há um objeto * Mapper e, se houver, então, leve a classe constante através de take.

Source: https://habr.com/ru/post/pt422161/


All Articles