Fermer

décembre 30, 2020

Une introduction pratique à l'injection de dépendances


À propos de l'auteur

Jamie est un développeur de logiciels de 18 ans situé au Texas. Il a un intérêt particulier pour l'architecture d'entreprise (DDD / CQRS / ES), l'écriture élégante et testable…
En savoir plus sur
Jamie

Cet article fournira une introduction pratique à l'injection de dépendance d'une manière qui vous permettra immédiatement de réaliser ses nombreux avantages sans être gêné par la théorie.

Le concept d'injection de dépendance est, à la base, une notion fondamentalement simple. Cependant, il est généralement présenté d'une manière à côté des concepts plus théoriques d'Inversion de Contrôle, d'Inversion de Dépendance, des Principes SOLID, et ainsi de suite. Pour vous faciliter au maximum la mise en œuvre de l'injection de dépendances et commencer à en récolter les avantages, cet article restera très axé sur le côté pratique de l'histoire, illustrant des exemples qui montrent précisément les avantages de son utilisation, d'une manière principalement divorcé de la théorie associée. Nous ne passerons que très peu de temps à discuter des concepts académiques qui entourent l'injection de dépendances ici, car l'essentiel de cette explication sera réservé au deuxième article de cette série. En effet, des livres entiers peuvent être et ont été écrits qui fournissent un traitement plus approfondi et plus rigoureux des concepts.

Ici, nous allons commencer par une explication simple, passer à quelques autres exemples du monde réel, puis discuter de quelques informations générales. Un autre article (pour suivre celui-ci) expliquera comment l'injection de dépendances s'intègre dans l'écosystème global de l'application des modèles architecturaux des meilleures pratiques.

A Simple Explanation

«Dependency Injection» est un terme trop complexe pour désigner un concept extrêmement simple . À ce stade, certaines questions judicieuses et raisonnables seraient «comment définissez-vous la« dépendance »?», «Que signifie le fait qu’une dépendance soit« injectée »?», «Pouvez-vous injecter des dépendances de différentes manières?» et "pourquoi est-ce utile?" Vous pourriez ne pas croire qu'un terme tel que «Injection de dépendances» peut être expliqué en deux extraits de code et quelques mots, mais hélas, c'est possible.

La façon la plus simple d'expliquer le concept est de vous montrer.

Ceci, par exemple, n'est pas injection de dépendances:

 import {Engine} from './Engine';

Voiture de classe {
    moteur privé: moteur;

    constructeur public () {
        this.engine = nouveau moteur ();
    }

    public startEngine (): void {
        this.engine.fireCylinders ();
    }
} 

Mais ceci est injection de dépendances:

 import {Engine} from './Engine';

Voiture de classe {
    moteur privé: moteur;

    constructeur public (moteur: moteur) {
        this.engine = moteur;
    }
    
    public startEngine (): void {
        this.engine.fireCylinders ();
    }
} 

Terminé. C'est ça. Cool. La fin.

Qu'est-ce qui a changé? Plutôt que d'autoriser la classe Car à instancier Engine (comme dans le premier exemple), dans le deuxième exemple, Car avait une instance de Le moteur est passé – ou injecté – d'un niveau de contrôle supérieur à son constructeur. C'est ça. À la base, tout cela est l'injection de dépendances – l'acte d'injecter (passer) une dépendance dans une autre classe ou fonction. Tout autre élément impliquant la notion d'injection de dépendances n'est qu'une variation de ce concept fondamental et simple. En termes simples, l'injection de dépendances est une technique par laquelle un objet reçoit d'autres objets dont il dépend, appelés dépendances, plutôt que de les créer lui-même.

En général, pour définir ce qu'est une «dépendance», si une classe A utilise la fonctionnalité d'une classe B puis B est une dépendance pour A ou, en d'autres termes, A a une dépendance de B . Bien sûr, cela ne se limite pas aux classes et vaut également pour les fonctions. Dans ce cas, la classe Car a une dépendance de la classe Engine ou Engine est une dépendance de Car . Les dépendances sont simplement des variables, comme la plupart des choses en programmation.

L'injection de dépendances est largement utilisée pour prendre en charge de nombreux cas d'utilisation, mais la plus flagrante des utilisations est peut-être de permettre des tests plus faciles. Dans le premier exemple, nous ne pouvons pas facilement simuler le moteur car la classe Car l'instancie. Le vrai moteur est toujours utilisé. Mais, dans ce dernier cas, nous contrôlons le Engine qui est utilisé, ce qui signifie, dans un test, que nous pouvons sous-classer Engine et remplacer ses méthodes.

Par exemple , si nous voulions voir ce que Car.startEngine () fait si engine.fireCylinders () renvoie une erreur, nous pourrions simplement créer une classe FakeEngine avoir il étend la classe Engine puis écrase fireCylinders pour qu'il génère une erreur. Dans le test, nous pouvons injecter cet objet FakeEngine dans le constructeur de Car . Puisque FakeEngine est un Engine par implication de l'héritage, le système de type TypeScript est satisfait.

Je veux dire très, très clairement que ce que vous voyez ci-dessus est la notion de base d'injection de dépendance. Une voiture en elle-même, n'est pas assez intelligente pour savoir de quel moteur elle a besoin. Seuls les ingénieurs qui construisent la voiture comprennent les exigences de ses moteurs et de ses roues. Ainsi, il est logique que les personnes qui construisent la voiture fournissent le moteur spécifique requis, plutôt que de laisser une voiture choisir elle-même le moteur qu'elle souhaite utiliser.

J'utilise le moteur. mot «construire» spécifiquement parce que vous construisez la voiture en appelant le constructeur, qui est l'endroit où les dépendances sont injectées. Si la voiture a également créé ses propres pneus en plus du moteur, comment savons-nous que les pneus utilisés peuvent être tournés en toute sécurité au régime maximal que le moteur peut produire? Pour toutes ces raisons et bien d'autres, il devrait être logique, peut-être intuitivement, que Car n'ait rien à voir avec le choix du moteur et des roues qu'il utilise. Ils devraient être fournis à partir d'un niveau de contrôle plus élevé.

Dans le dernier exemple illustrant l'injection de dépendances en action, si vous imaginez Engine comme une classe abstraite plutôt que concrète, cela devrait faire encore plus sens – la voiture sait qu'elle a besoin d'un moteur et elle sait que le moteur doit avoir des fonctionnalités de base, mais comment ce moteur est géré et quelle est l'implémentation spécifique de celui-ci est réservé pour être décidé et fourni par le morceau de code qui crée (

Un exemple du monde réel

Nous allons regarder quelques exemples plus pratiques qui, espérons-le, aideront à expliquer, encore une fois intuitivement, pourquoi l'injection de dépendances est utile. Espérons qu'en ne vous inspirant pas de la théorie et en passant directement aux concepts applicables, vous pourrez voir plus pleinement les avantages qu'apporte l'injection de dépendance et les difficultés de la vie sans elle. Nous reviendrons à un traitement un peu plus «académique» du sujet plus tard.

Nous commencerons par construire notre application normalement, d'une manière hautement couplée, sans utiliser d'injection de dépendances ou d'abstractions, de sorte que nous venons à voir le les inconvénients de cette approche et la difficulté qu’elle ajoute aux tests. En cours de route, nous refactoriserons progressivement jusqu'à ce que nous corrigions tous les problèmes.

Pour commencer, supposons que vous ayez été chargé de créer deux classes – un fournisseur de messagerie et une classe pour une couche d'accès aux données qui doit être utilisée par certains UserService . Nous allons commencer par l'accès aux données, mais les deux sont faciles à définir:

 // UserRepository.ts

importer {dbDriver} depuis 'pg-driver';

classe d'exportation UserRepository {
    public async addUser (utilisateur: Utilisateur): Promise  {
        // ... dbDriver.save (...)
    }

    public async findUserById (id: string): Promise  {
        // ... dbDriver.query (...)
    }
    
    public async existsByEmail (email: string): Promise  {
        // ... dbDriver.save (...)
    }
} 

Note: Le nom «Repository» vient ici du «Repository Pattern», une méthode de découplage de votre base de données de votre logique métier. Vous pouvez en savoir plus sur le modèle de référentiel mais pour les besoins de cet article, vous pouvez simplement le considérer comme une classe qui encapsule votre base de données afin que, dans la logique métier, votre système de stockage de données soit traité comme une simple collection en mémoire. L'explication complète du modèle de référentiel n'entre pas dans le cadre de cet article.

C'est ainsi que nous nous attendons normalement à ce que les choses fonctionnent, et dbDriver est codé en dur dans le fichier.

Dans votre ] UserService vous importez la classe, l'instanciez et commencez à l'utiliser:

 import {UserRepository} from './UserRepository.ts';

classe UserService {
    userRepository privé en lecture seule: UserRepository;
    
    constructeur public () {
        // Pas d'injection de dépendance.
        this.userRepository = nouveau UserRepository ();
    }

    public async registerUser (dto: IRegisterUserDto): Promise  {
        // Objet utilisateur et validation
        const user = User.fromDto (dto);

        if (attendez this.userRepository.existsByEmail (dto.email))
            return Promise.reject (nouveau DuplicateEmailError ());
            
        // Persistance de la base de données
        attendez this.userRepository.addUser (utilisateur);
        
        // Envoyer un email de bienvenue
        // ...
    }

    public async findUserById (id: string): Promise  {
        // Pas besoin d'attendre ici, la promesse sera déballée par l'appelant.
        return this.userRepository.findUserById (id);
    }
} 

Encore une fois, tout reste normal.

Bref aparté: Un DTO est un objet de transfert de données – c'est un objet qui agit comme un sac de propriétés pour définir une forme de données normalisée lors de son déplacement entre deux systèmes externes ou deux couches d'une application. Vous pouvez en savoir plus sur les DTO dans l’article de Martin Fowler sur le sujet, ici . Dans ce cas, IRegisterUserDto définit un contrat pour ce que devrait être la forme des données telles qu'elles proviennent du client. Je n'ai que deux propriétés – id et email . Vous pourriez penser qu'il est étrange que le DTO que nous attendons du client pour créer un nouvel utilisateur contienne l'identifiant de l'utilisateur même si nous n'avons pas encore créé d'utilisateur. L'ID est un UUID et j'autorise le client à le générer pour diverses raisons, qui sortent du cadre de cet article. De plus, la fonction findUserById devrait mapper l'objet User à une réponse DTO, mais j'ai négligé cela par souci de concision. Enfin, dans le monde réel, je n’aurais pas de modèle de domaine User contenant une méthode fromDto . Ce n’est pas bon pour la pureté du domaine. Encore une fois, son but est ici la brièveté.

Ensuite, vous voulez gérer l'envoi de courriels. Une fois de plus, comme d'habitude, vous pouvez simplement créer une classe de fournisseur de messagerie et l'importer dans votre UserService .

 // SendGridEmailProvider.ts

import {sendMail} de 'sendgrid';

classe d'exportation SendGridEmailProvider {
    public async sendWelcomeEmail (à: chaîne): Promise  {
        // ... attendez sendMail (...);
    }
} 

Dans UserService :

 import {UserRepository} depuis './UserRepository.ts';
import {SendGridEmailProvider} de './SendGridEmailProvider.ts';

classe UserService {
    userRepository privé en lecture seule: UserRepository;
    privé en lecture seule sendGridEmailProvider: SendGridEmailProvider;

    constructeur public () {
        // Ne fait toujours pas d'injection de dépendances.
        this.userRepository = nouveau UserRepository ();
        this.sendGridEmailProvider = nouveau SendGridEmailProvider ();
    }

    public async registerUser (dto: IRegisterUserDto): Promise  {
        // Objet utilisateur et validation
        const user = User.fromDto (dto);
        
        if (attendez this.userRepository.existsByEmail (dto.email))
            return Promise.reject (nouveau DuplicateEmailError ());
        
        // Persistance de la base de données
        attendez this.userRepository.addUser (utilisateur);
        
        // Envoyer un e-mail de bienvenue
        attendez this.sendGridEmailProvider.sendWelcomeEmail (user.email);
    }

    public async findUserById (id: string): Promise  {
        return this.userRepository.findUserById (id);
    }
} 

Nous avons maintenant une classe ouvrière à part entière, et dans un monde où nous ne nous soucions pas de la testabilité ou de l’écriture de code propre, quelle qu’en soit la définition, et dans un monde où la dette technique est inexistante et embêtante les responsables de programme ne fixent pas de délais, c'est parfaitement bien. Malheureusement, ce n’est pas un monde dans lequel nous avons l’avantage de vivre.

Que se passe-t-il lorsque nous décidons de quitter SendGrid pour recevoir des e-mails et d’utiliser MailChimp à la place? De même, que se passe-t-il lorsque nous voulons tester nos méthodes unitaire – allons-nous utiliser la vraie base de données dans les tests? Pire encore, allons-nous envoyer de vrais e-mails à des adresses e-mail potentiellement réelles et payer pour cela aussi?

Dans l'écosystème JavaScript traditionnel, les méthodes de tests unitaires des classes sous cette configuration sont lourdes de complexité et de sur-ingénierie. Les gens apportent des bibliothèques entières simplement pour fournir des fonctionnalités de stubbing, qui ajoutent toutes sortes de couches d'indirection, et, pire encore, peuvent directement coupler les tests à la mise en œuvre du système testé, alors qu'en réalité, les tests ne devraient jamais savoir comment le vrai système fonctionne (c'est ce qu'on appelle le test de la boîte noire). Nous travaillerons à atténuer ces problèmes en discutant de la responsabilité réelle de UserService et en appliquant de nouvelles techniques d'injection de dépendances.

Considérez, pendant un instant, ce qu'est un UserService Est-ce que. Le but de l'existence de UserService est d'exécuter des cas d'utilisation spécifiques impliquant des utilisateurs – les enregistrer, les lire, les mettre à jour, etc. Il est préférable que les classes et les fonctions n'aient qu'une seule responsabilité (SRP – le principe de responsabilité unique), et la responsabilité de UserService est de gérer les opérations liées à l'utilisateur. Pourquoi, alors, UserService est-il responsable du contrôle de la durée de vie de UserRepository et SendGridEmailProvider dans cet exemple?

Imaginez si nous avions une autre classe utilisée par UserService qui a ouvert une connexion de longue durée. UserService devrait-il également être responsable de l'élimination de cette connexion? Bien sûr que non. Toutes ces dépendances ont une durée de vie qui leur est associée – elles pourraient être des singletons, elles pourraient être transitoires et étendues à une requête HTTP spécifique, etc. Le contrôle de ces durées de vie est bien en dehors de la compétence de UserService . Donc, pour résoudre ces problèmes, nous allons injecter toutes les dépendances dans, comme nous l'avons vu précédemment.

 import {UserRepository} from './UserRepository.ts';
import {SendGridEmailProvider} de './SendGridEmailProvider.ts';

classe UserService {
    userRepository privé en lecture seule: UserRepository;
    privé en lecture seule sendGridEmailProvider: SendGridEmailProvider;

    constructeur public (
        userRepository: UserRepository,
        sendGridEmailProvider: SendGridEmailProvider
    ) {
        // Yay! Les dépendances sont injectées.
        this.userRepository = userRepository;
        this.sendGridEmailProvider = sendGridEmailProvider;
    }

    public async registerUser (dto: IRegisterUserDto): Promise  {
        // Objet utilisateur et validation
        const user = User.fromDto (dto);

        if (attendez this.userRepository.existsByEmail (dto.email))
            return Promise.reject (nouveau DuplicateEmailError ());
        
        // Persistance de la base de données
        attendez this.userRepository.addUser (utilisateur);
        
        // Envoyer un e-mail de bienvenue
        attendez this.sendGridEmailProvider.sendWelcomeEmail (user.email);
    }

    public async findUserById (id: string): Promise  {
        return this.userRepository.findUserById (id);
    }
} 

Génial! Désormais, UserService reçoit des objets pré-instanciés, et le morceau de code qui appelle et crée un nouveau UserService est le morceau de code chargé de contrôler la durée de vie des dépendances. Nous avons inversé le contrôle de UserService et à un niveau supérieur. Si je voulais seulement montrer comment nous pourrions injecter des dépendances via le constructeur pour expliquer le locataire de base de l'injection de dépendances, je pourrais m'arrêter ici. Il y a encore quelques problèmes du point de vue de la conception, cependant, qui, une fois corrigés, serviront à rendre notre utilisation de l'injection de dépendances d'autant plus puissante.

Premièrement, pourquoi UserService sait que nous utilisons SendGrid pour les e-mails? Deuxièmement, les deux dépendances sont sur des classes concrètes – le concret UserRepository et le concret SendGridEmailProvider . Cette relation est trop rigide – nous sommes obligés de passer un objet qui est un UserRepository et un SendGridEmailProvider .

Ce n'est pas génial parce que nous voulons ] UserService pour être totalement indépendant de l'implémentation de ses dépendances. En ayant UserService aveugle de cette manière, nous pouvons échanger les implémentations sans affecter le service du tout – cela signifie que si nous décidons de migrer loin de SendGrid et d'utiliser MailChimp à la place, nous pouvons le faire. Cela signifie également que si nous voulons simuler le fournisseur de messagerie pour les tests, nous pouvons le faire aussi.

Ce qui serait utile, c'est si nous pouvions définir une interface publique et forcer les dépendances entrantes à respecter cette interface, tout en ayant UserService être indépendant des détails d'implémentation. En d'autres termes, nous devons forcer UserService à ne dépendre que d'une abstraction de ses dépendances, et non de dépendances concrètes. Nous pouvons le faire via des interfaces.

Commencez par définir une interface pour le UserRepository et implémentez-la:

 // UserRepository.ts

importer {dbDriver} depuis 'pg-driver';

interface d'exportation IUserRepository {
    addUser (utilisateur: Utilisateur): Promise ;
    findUserById (id: chaîne): Promise ;
    existByEmail (email: chaîne): Promise ;
}

la classe d'exportation UserRepository implémente IUserRepository {
    public async addUser (utilisateur: Utilisateur): Promise  {
        // ... dbDriver.save (...)
    }

    public async findUserById (id: string): Promise  {
        // ... dbDriver.query (...)
    }

    public async existsByEmail (email: string): Promise  {
        // ... dbDriver.save (...)
    }
} 

Et définissez-en un pour le fournisseur de messagerie, en l'implémentant également:

 // IEmailProvider.ts
interface d'exportation IEmailProvider {
    sendWelcomeEmail (à: chaîne): Promise ;
}

// SendGridEmailProvider.ts
import {sendMail} de 'sendgrid';
import {IEmailProvider} de './IEmailProvider';

La classe d'exportation SendGridEmailProvider implémente IEmailProvider {
    public async sendWelcomeEmail (à: chaîne): Promise  {
        // ... attendez sendMail (...);
    }
} 

Remarque: Voici le Adapter Pattern du Gang of Four Design Patterns.

Maintenant, notre UserService peut dépendre de les interfaces plutôt que les implémentations concrètes des dépendances:

 import {IUserRepository} de './UserRepository.ts';
import {IEmailProvider} de './SendGridEmailProvider.ts';

classe UserService {
    userRepository privé en lecture seule: IUserRepository;
    emailProvider privé en lecture seule: IEmailProvider;

    constructeur public (
        userRepository: IUserRepository,
        emailProvider: IEmailProvider
    ) {
        // Double yay! Injection de dépendances et codage contre des interfaces.
        this.userRepository = userRepository;
        this.emailProvider = emailProvider;
    }

    public async registerUser (dto: IRegisterUserDto): Promise  {
        // Objet utilisateur et validation
        const user = User.fromDto (dto);

        if (attendez this.userRepository.existsByEmail (dto.email))
            return Promise.reject (nouveau DuplicateEmailError ());
        
        // Persistance de la base de données
        attendez this.userRepository.addUser (utilisateur);
        
        // Envoyer un e-mail de bienvenue
        attendez this.emailProvider.sendWelcomeEmail (user.email);
    }

    public async findUserById (id: string): Promise  {
        return this.userRepository.findUserById (id);
    }
} 

Si les interfaces sont nouvelles pour vous, cela peut sembler très, très complexe. En effet, le concept de création de logiciels faiblement couplés pourrait également être nouveau pour vous. Pensez aux prises murales. Vous pouvez brancher n'importe quel appareil sur n'importe quelle prise à condition que la fiche s'adapte à la prise. C’est un couplage lâche en action. Votre grille-pain n'est pas câblé dans le mur, car si c'était le cas et que vous décidiez de le mettre à niveau, vous n'avez pas de chance. Au lieu de cela, des prises sont utilisées et la prise définit l'interface. De même, lorsque vous branchez un appareil électronique dans votre prise murale, vous n'êtes pas concerné par le potentiel de tension, la consommation maximale de courant, la fréquence CA, etc., vous vous souciez simplement de savoir si la fiche s'insère dans la prise. Vous pourriez faire venir un électricien et changer tous les fils derrière cette prise, et vous n'aurez aucun problème à brancher votre grille-pain, tant que cette prise ne change pas. De plus, votre source d'électricité pourrait être commutée pour venir de la ville ou de vos propres panneaux solaires, et encore une fois, vous ne vous en souciez pas tant que vous pouvez toujours vous brancher sur cette prise.

L'interface est la prise, fournissant " «plug-and-play». Dans cet exemple, le câblage dans le mur et la source d'électricité s'apparente aux dépendances et votre grille-pain s'apparente au UserService (il a une dépendance à l'électricité) – la source d'électricité peut changer et le grille-pain fonctionne toujours bien et n'a pas besoin d'être touché, car la prise, agissant comme l'interface, définit les moyens standard pour les deux de communiquer. En fait, on pourrait dire que la prise agit comme une «abstraction» du câblage mural, des disjoncteurs, de la source électrique, etc.

C'est un principe courant et bien considéré de la conception de logiciels, pour les raisons ci-dessus , pour coder contre des interfaces (abstractions) et non des implémentations, c'est ce que nous avons fait ici. Ce faisant, nous avons la liberté d'échanger les implémentations à notre guise, car ces implémentations sont cachées derrière l'interface (tout comme le câblage mural est caché derrière la prise), et donc la logique métier qui utilise la dépendance n'a jamais à le faire. changer tant que l'interface ne change jamais. N'oubliez pas que UserService a seulement besoin de savoir quelle fonctionnalité est offerte par ses dépendances, pas comment cette fonctionnalité est prise en charge en arrière-plan. C'est pourquoi l'utilisation des interfaces fonctionne.

Ces deux changements simples d'utilisation d'interfaces et d'injection de dépendances font toute la différence dans le monde lorsqu'il s'agit de créer des logiciels faiblement couplés et résout tous les problèmes que nous avons rencontrés ci-dessus.

Si nous décider demain que nous voulons nous fier à Mailchimp pour les courriels, nous créons simplement une nouvelle classe Mailchimp qui honore l'interface IEmailProvider et l'injectons à la place de SendGrid. La classe actuelle UserService ne doit jamais changer, même si nous venons d’apporter une énorme modification à notre système en passant à un nouveau fournisseur de messagerie. La beauté de ces modèles est que UserService reste parfaitement inconscient de la façon dont les dépendances qu'il utilise fonctionnent dans les coulisses. L'interface sert de frontière architecturale entre les deux composants, les maintenant découplés de manière appropriée.

De plus, quand il s'agit de tester, nous pouvons créer des faux qui respectent les interfaces et les injecter à la place. Ici, vous pouvez voir un faux dépôt et un faux fournisseur de messagerie.

 // Les deux faux:
La classe FakeUserRepository implémente IUserRepository {
    utilisateurs privés en lecture seule: User [] = [];

    public async addUser (utilisateur: Utilisateur): Promise  {
        this.users.push (utilisateur);
    }

    public async findUserById (id: string): Promise  {
        const userOrNone = this.users.find (u => u.id === id);

        return userOrNone
            ? Promise.resolve (userOrNone)
            : Promise.reject (nouveau NotFoundError ());
    }

    public async existsByEmail (email: string): Promise  {
        return Boolean (this.users.find (u => u.email === email));
    }

    public getPersistedUserCount = () => this.users.length;
}

La classe FakeEmailProvider implémente IEmailProvider {
    emailRecipients privé en lecture seule: string [] = [];

    public async sendWelcomeEmail (à: chaîne): Promise  {
        this.emailRecipients.push (à);
    }

    public wasEmailSentToRecipient = (destinataire: chaîne) =>
        Boolean (this.emailRecipients.find (r => r === destinataire));
} 

Notez que les deux faux implémentent les mêmes interfaces que UserService s'attend à ce que ses dépendances respectent. Maintenant, nous pouvons passer ces faux dans UserService au lieu des classes réelles et UserService n'en sera pas plus sage; il les utilisera comme s’ils étaient la vraie affaire. La raison pour laquelle il peut le faire est qu'il sait que toutes les méthodes et propriétés qu'il souhaite utiliser sur ses dépendances existent bel et bien et sont effectivement accessibles (car elles implémentent les interfaces), ce qui est tout ce dont UserService a besoin. à savoir (c'est-à-dire pas comment fonctionnent les dépendances).

Nous allons injecter ces deux pendant les tests, et cela rendra le processus de test tellement plus facile et tellement plus simple que ce à quoi vous pourriez être habitué lorsque vous traitez avec mocking et stubbing over-the-top, travaillant avec les outils internes de Jest, ou essayant de monkey-patch.

Voici des tests réels utilisant les faux:

 // Fakes
laissez fakeUserRepository: FakeUserRepository;
laissez fakeEmailProvider: FakeEmailProvider;

// SUT
laissez userService: UserService;

// Nous voulons nettoyer les tableaux internes des deux faux
// avant chaque test.
beforeEach (() => {
    fakeUserRepository = nouveau FakeUserRepository ();
    fakeEmailProvider = nouveau FakeEmailProvider ();
    
    userService = nouveau UserService (fakeUserRepository, fakeEmailProvider);
});

// Une fabrique pour créer facilement des DTO.
// Ici, nous avons le choix facultatif de remplacer les valeurs par défaut
// grâce au type d'utilitaire intégré `Partial` de TypeScript.
function createSeedRegisterUserDto (opts?: Partial ): IRegisterUserDto {
    revenir {
        id: 'someId',
        email: 'exemple@domaine.com',
        ... opte
    };
}

test ('devrait persister correctement un utilisateur et envoyer un e-mail', async () => {
    // Organiser
    const dto = createSeedRegisterUserDto ();

    // Agir
    attendre userService.registerUser (dto);

    // Affirmer
    const expectedUser = User.fromDto (dto);
    const persistedUser = attendre fakeUserRepository.findUserById (dto.id);
    
    const wasEmailSent = fakeEmailProvider.wasEmailSentToRecipient (dto.email);

    expect (utilisateur persistant) .toEqual (utilisateur attendu);
    expect (wasEmailSent) .toBe (true);
});

test ('devrait rejeter avec un DuplicateEmailError si un email existe déjà', async () => {
    // Organiser
    const existingEmail = 'john.doe@live.com';
    const dto = createSeedRegisterUserDto ({email: existingEmail});
    const existingUser = User.fromDto (dto);
    
    attendre fakeUserRepository.addUser (existingUser);

    // Agir, affirmer
    attendre attendre (userService.registerUser (dto))
        .rejects.toBeInstanceOf (DuplicateEmailError);

    expect (fakeUserRepository.getPersistedUserCount ()). toBe (1);
});

test ('devrait renvoyer correctement un utilisateur', async () => {
    // Organiser
    const user = User.fromDto (createSeedRegisterUserDto ());
    attendre fakeUserRepository.addUser (utilisateur);

    // Agir
    const receiveUser = attendre userService.findUserById (user.id);

    // Affirmer
    expect (recuUser) .toEqual (utilisateur);
}); 

Vous remarquerez quelques petites choses ici: les contrefaçons manuscrites sont très simples. Il n’ya pas de complexité avec les frameworks moqueurs qui ne servent qu’à obscurcir. Tout est roulé à la main et cela signifie qu'il n'y a pas de magie dans la base de code. Le comportement asynchrone est simulé pour correspondre aux interfaces. J'utilise async / await dans les tests même si tout le comportement est synchrone parce que je pense qu'il correspond plus étroitement à la façon dont je m'attendrais à ce que les opérations fonctionnent dans le monde réel et parce qu'en ajoutant async / await, je peux exécuter cette même suite de tests contre les implémentations réelles en plus des faux, il est donc nécessaire de gérer l'asynchronisme de manière appropriée. En fait, dans la vraie vie, je ne me soucierais probablement même pas de me moquer de la base de données et utiliserais plutôt une base de données locale dans un conteneur Docker jusqu'à ce qu'il y ait tellement de tests que je devais me moquer de cela pour les performances. Je pourrais ensuite exécuter les tests DB en mémoire après chaque changement et réserver les vrais tests DB locaux pour juste avant de valider les modifications et pour sur le serveur de construction dans le pipeline CI / CD.

Dans le premier test, dans le " organiser », nous créons simplement le DTO. Dans la section «act», nous appelons le système testé et exécutons son comportement. Les choses deviennent un peu plus complexes lors des affirmations. N'oubliez pas qu'à ce stade du test, nous ne savons même pas si l'utilisateur a été enregistré correctement. Donc, nous définissons à quoi nous nous attendons d'un utilisateur persistant, puis nous appelons le faux référentiel et lui demandons un utilisateur avec l'ID que nous attendons. Si le UserService n'a pas persisté l'utilisateur correctement, cela lancera une NotFoundError et le test échouera, sinon, il nous rendra l'utilisateur. Ensuite, nous appelons le faux fournisseur de messagerie et lui demandons s'il a enregistré l'envoi d'un e-mail à cet utilisateur. Enfin, nous faisons les affirmations avec Jest et cela conclut le test. Il est expressif et se lit comme le fonctionnement réel du système. Il n'y a pas d'indirection des bibliothèques moqueuses et il n'y a pas de couplage avec l'implémentation du UserService .

Dans le deuxième test, nous créons un utilisateur existant et l'ajoutons au référentiel, puis nous essayons d'appeler le service à nouveau en utilisant un DTO qui a déjà été utilisé pour créer et conserver un utilisateur, et nous nous attendons à ce que cela échoue. Nous affirmons également qu'aucune nouvelle donnée n'a été ajoutée au référentiel.

Pour le troisième test, la section «organiser» consiste maintenant à créer un utilisateur et à le conserver dans le faux référentiel. Ensuite, nous appelons le SUT, et enfin, nous vérifions si l'utilisateur qui revient est celui que nous avons sauvegardé dans le dépôt plus tôt.

Ces exemples sont relativement simples, mais lorsque les choses deviennent plus complexes, pouvoir compter sur l'injection de dépendances et les interfaces de cette manière gardent votre code propre et font de l'écriture des tests un plaisir.

Un petit aparté sur les tests: En général, vous n'avez pas besoin de simuler toutes les dépendances que le code utilise. De nombreuses personnes, à tort, affirment qu'une «unité» dans un «test unitaire» est une fonction ou une classe. Cela ne pouvait pas être plus incorrect. L '«unité» est définie comme «l'unité de fonctionnalité» ou «l'unité de comportement», et non comme une fonction ou une classe. So if a unit of behavior uses 5 different classes, you don’t need to mock out all those classes unless they reach outside of the boundary of the module. In this case, I mocked the database and I mocked the email provider because I have no choice. If I don’t want to use a real database and I don’t want to send an email, I have to mock them out. But if I had a bunch more classes that didn’t do anything across the network, I would not mock them because they’re implementation details of the unit of behavior. I could also decide against mocking the database and emails and spin up a real local database and a real SMTP server, both in Docker containers. On the first point, I have no problem using a real database and still calling it a unit test so long as it’s not too slow. Generally, I’d use the real DB first until it became too slow and I had to mock, as discussed above. But, no matter what you do, you have to be pragmatic — sending welcome emails is not a mission-critical operation, thus we don’t need to go that far in terms of SMTP servers in Docker containers. Whenever I do mock, I would be very unlikely to use a mocking framework or try to assert on the number of times called or parameters passed except in very rare cases, because that would couple tests to the implementation of the system under test, and they should be agnostic to those details.

Performing Dependency Injection Without Classes And Constructors

So far, throughout the article, we’ve worked exclusively with classes and injected the dependencies through the constructor. If you’re taking a functional approach to development and wish not to use classes, one can still obtain the benefits of dependency injection using function arguments. For example, our UserService class above could be refactored into:

function makeUserService(
    userRepository: IUserRepository,
    emailProvider: IEmailProvider
): IUserService {
    return {
        registerUser: async dto => {
            // ...
        },

        findUserById: id => userRepository.findUserById(id)
    }
}

It’s a factory that receives the dependencies and constructs the service object. We can also inject dependencies into Higher Order Functions. A typical example would be creating an Express Middleware function that gets a UserRepository and an ILogger injected:

function authProvider(userRepository: IUserRepository, logger: ILogger) {
    return async (req: Request, res: Response, next: NextFunction) => {
        // ...
        // Has access to userRepository, logger, req, res, and next.
    }
}

In the first example, I didn’t define the type of dto and id because if we define an interface called IUserService containing the method signatures for the service, then the TS Compiler will infer the types automatically. Similarly, had I defined a function signature for the Express Middleware to be the return type of authProviderI wouldn’t have had to declare the argument types there either.

If we considered the email provider and the repository to be functional too, and if we injected their specific dependencies as well instead of hard coding them, the root of the application could look like this:

import { sendMail } from 'sendgrid';

async function main() {
    const app = express();
    
    const dbConnection = await connectToDatabase();
    
    // Change emailProvider to `makeMailChimpEmailProvider` whenever we want
    // with no changes made to dependent code.
    const userRepository = makeUserRepository(dbConnection);
    const emailProvider = makeSendGridEmailProvider(sendMail);
    
    const userService = makeUserService(userRepository, emailProvider);

    // Put this into another file. It’s a controller action.
    app.post('/login', (req, res) => {
        await userService.registerUser(req.body as IRegisterUserDto);
        return res.send();
    });

    // Put this into another file. It’s a controller action.
    app.delete(
        '/me', 
        authProvider(userRepository, emailProvider), 
        (req, res) => { ... }
    );
}

Notice that we fetch the dependencies that we need, like a database connection or third-party library functions, and then we utilize factories to make our first-party dependencies using the third-party ones. We then pass them into the dependent code. Since everything is coded against abstractions, I can swap out either userRepository or emailProvider to be any different function or class with any implementation I want (that still implements the interface correctly) and UserService will just use it with no changes needed, which, once again, is because UserService cares about nothing but the public interface of the dependencies, not how the dependencies work.

As a disclaimer, I want to point out a few things. As stated earlier, this demo was optimized for showing how dependency injection makes life easier, and thus it wasn’t optimized in terms of system design best practices insofar as the patterns surrounding how Repositories and DTOs should technically be used. In real life, one has to deal with managing transactions across repositories and the DTO should generally not be passed into service methods, but rather mapped in the controller to allow the presentation layer to evolve separately from the application layer. The userSerivce.findById method here also neglects to map the User domain object to a DTO, which it should do in real life. None of this affects the DI implementation though, I simply wanted to keep the focus on the benefits of DI itself, not Repository design, Unit of Work management, or DTOs. Finally, although this may look a little like the NestJS framework in terms of the manner of doing things, it’s not, and I actively discourage people from using NestJS for reasons outside the scope of this article.

A Brief Theoretical Overview

All applications are made up of collaborating components, and the manner in which those collaborators collaborate and are managed will decide how much the application will resist refactoring, resist change, and resist testing. Dependency injection mixed with coding against interfaces is a primary method (among others) of reducing the coupling of collaborators within systems, and making them easily swappable. This is the hallmark of a highly cohesive and loosely coupled design.

The individual components that make up applications in non-trivial systems must be decoupled if we want the system to be maintainable, and the way we achieve that level of decoupling, as stated above, is by depending upon abstractions, in this case, interfaces, rather than concrete implementations, and utilizing dependency injection. Doing so provides loose coupling and gives us the freedom of swapping out implementations without needing to make any changes on the side of the dependent component/collaborator and solves the problem that dependent code has no business managing the lifetime of its dependencies and shouldn’t know how to create them or dispose of them.

Despite the simplicity of what we’ve seen thus far, there’s a lot more complexity that surrounds dependency injection.

Injection of dependencies can come in many forms. Constructor Injection is what we have been using here since dependencies are injected into a constructor. There also exists Setter Injection and Interface Injection. In the case of the former, the dependent component will expose a setter method which will be used to inject the dependency — that is, it could expose a method like setUserRepository(userRepository: UserRepository). In the last case, we can define interfaces through which to perform the injection, but I’ll omit the explanation of the last technique here for brevity since we’ll spend more time discussing it and more in the second article of this series.

Because wiring up dependencies manually can be difficult, various IoC Frameworks and Containers exist. These containers store your dependencies and resolve the correct ones at runtime, often through Reflection in languages like C# or Java, exposing various configuration options for dependency lifetime. Despite the benefits that IoC Containers provide, there are cases to be made for moving away from them, and only resolving dependencies manually. To hear more about this, see Greg Young’s 8 Lines of Code talk.

Additionally, DI Frameworks and IoC Containers can provide too many options, and many rely on decorators or attributes to perform techniques such as setter or field injection. I look down on this kind of approach because, if you think about it intuitively, the point of dependency injection is to achieve loose coupling, but if you begin to sprinkle IoC Container-specific decorators all over your business logic, while you may have achieved decoupling from the dependency, you’ve inadvertently coupled yourself to the IoC Container. IoC Containers like Awilix solve this problem since they remain divorced from your application’s business logic.

Conclusion

This article served to depict only a very practical example of dependency injection in use and mostly neglected the theoretical attributes. I did it this way in order to make it easier to understand what dependency injection is at its core in a manner divorced from the rest of the complexity that people usually associate with the concept.

In the second article of this series, we’ll take a much, much more in-depth look, including at:

  • The difference between Dependency Injection and Dependency Inversion and Inversion of Control;
  • Dependency Injection anti-patterns;
  • IoC Container anti-patterns;
  • The role of IoC Containers;
  • The different types of dependency lifetimes;
  • How IoC Containers are designed;
  • Dependency Injection with React;
  • Advanced testing scenarios;
  • And more.

Stay tuned!

Smashing Editorial(ra, yk, il)




Source link