Abaisser le niveau de connectivité à l'aide de DI pour améliorer la testabilité du code, exemple d'implémentation

Au début de l'article, je tiens à noter immédiatement que je ne prétends pas être nouveau, mais seulement partager / rappeler une opportunité comme IoC DI.


De plus, je n'ai pratiquement aucune expérience dans la rédaction d'articles, c'est ma première. J'ai fait de mon mieux, si vous ne jugez pas strictement.


De quoi parle-t-on


La plupart des projets Rails que j'ai rencontrés ont un gros problème. Soit ils n'ont pas du tout de tests, soit leurs tests vérifient une partie insignifiante, tandis que la qualité de ces tests laisse beaucoup à désirer.


La raison principale en est que le développeur ne sait tout simplement pas comment écrire le code de sorte que dans les tests unitaires, testez uniquement le code écrit par lui, et non pas le code, qui, par exemple, est contenu dans un autre objet de service ou bibliothèque.


De plus, une chaîne logique est formée dans la tête du programmeur, mais pourquoi dois-je transférer le code logique métier vers une autre couche, j'ajouterai juste quelques lignes ici et tout répondra aux exigences du client.


Et c'est très mauvais, car les tests unitaires perdent leur résistance au refactoring, et la gestion des changements dans un tel code devient difficile. L'entropie augmente progressivement. Et si vous avez déjà peur de refactoriser votre code, les choses vont très mal.


Pour résoudre de tels problèmes dans le monde Java, un certain nombre de bibliothèques existent depuis longtemps et il n'y a pas de raison particulière de réinventer la roue, même s'il convient de noter que ces solutions sont très lourdes et qu'il n'y a pas toujours de raison de les utiliser. Les rubististes semblent en quelque sorte résoudre ces problèmes d'une manière différente, mais honnêtement, je ne comprenais toujours pas comment. Par conséquent, j'ai décidé de partager comment j'ai décidé de le faire.


L'idée générale de la façon de résoudre ce problème dans les projets rubis


L'idée de base est que pour les objets qui ont des dépendances, nous devrions pouvoir les gérer.


Prenons un exemple:


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 

Pour tester la méthode block_user, nous nous trouvons à un moment désagréable, car la notification de NotificationService fonctionnera pour nous et nous sommes obligés de traiter une partie minimale que cette méthode effectue.
L'inversion nous permet de simplement sortir de cette situation si nous implémentons un UserService, par exemple, comme ceci:


 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 

Maintenant, lors des tests, nous passons un objet en tant que maquette NotificationService et vérifions que block_user extrait les méthodes notification_service dans le bon ordre et avec les bons arguments.


 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 

Étude de cas pour les rails


Lorsqu'il y a beaucoup d'objets de service dans le système, il devient difficile de construire toutes les dépendances vous-même, le code commence à croître avec des lignes de code supplémentaires qui réduisent la lisibilité.


À cet égard, il m'est venu à l'esprit d'écrire un petit module qui automatise la gestion des dépendances.


 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 

Il y a une mise en garde, le service devrait être Singleton, c'est-à-dire avoir une méthode d'instance. Pour ce faire, la méthode la plus simple consiste à écrire include Singleton dans la classe de service.


Maintenant, dans ApplicationController, ajoutez


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

Et maintenant, dans les contrôleurs, nous pouvons le faire


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

Dans la spécification de ce contrôleur, nous obtenons automatiquement instance_double (WelcomeService) en tant que dépendance.


 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 

Ce qui peut être amélioré


Imaginez, par exemple, que dans notre système, il existe plusieurs options pour envoyer des notifications, par exemple, la nuit, ce sera un fournisseur et un autre pendant la journée. Dans le même temps, les fournisseurs ont des protocoles d'envoi complètement différents.


En général, l'interface NotificationService reste la même, mais il existe deux implémentations spécifiques.


 class NightlyNotificationService < NotificationService end class DailyNotificationService < NotificationService end 

Maintenant, nous pouvons écrire une classe qui effectuera un mappage conditionnel des services


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

Maintenant, lorsque nous prenons l'instance de service dans Services :: Helpers :: Service.instance, nous devons vérifier s'il existe un objet * Mapper, et si c'est le cas, puis prendre la constante de classe via take.

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


All Articles