Notes personnelles du chapitre 3
Pour ceux qui n'ont pas lu le chapitre précédant, c'est ici : CHAPITRE 2.
Voici quelques passages que j'ai relevés du chapitre 3, pour les exemples de code, j'utilise le langage Java associé au framework de test Junit5.
Ce chapitre présente diverses manières de mettre en place un Stub entre une dépendance externe et notre logique métier. À l'origine l'auteur à exporté la logique de validation des fichiers de logs dans cette dépendance, j'ai fait le choix de garder cette logique dans notre classe principale et d'utiliser cette dépendance pour récupérer le nom du fichier à valider.
Définition d'une dépendance externe ?
Une dépendance externe est un element dans votre système avec lequel votre code testé interagit et sur lequel vous n'avez aucun contrôle. (les exemples courants sont les systèmes de fichiers, une base de données, le temps, etc.)
Définition d'un Stub :
Un stub est un remplacement contrôlable d'une dépendance (ou d'un collaborateur) existante dans le système. En utilisant un stub, vous pouvez tester votre code sans avoir à traiter directement avec la dépendance.
Opposition entre les mocks et les stubs :
les mocks sont comme les stubs, à la différence qu'on fait une assertion sur l'objet mocké, contrairement aux stubs (les mocks seront vus en détails dans le chapitre 4).
Ajoutons une dépendance qui se charge de récupérer le nom du fichier :
FileExtensionManager est notre dépendance externe qui est ici implicite, en l'état, notre logique de validation n'est pas testable, nous n'avons aucun contrôle sur cette dépendance.
Les différentes étapes que nous allons suivre afin de rendre notre fonctionnalité testable :
- Trouver l'interface contre laquelle le début de la fonctionnalité testée fonctionne. (Dans ce cas, le terme "interface" n'est pas utilisé dans le sens purement orienté objet ; il fait référence à la méthode ou à la classe définie avec laquelle on collabore.) Dans notre projet LogAn, il s'agit du fichier de configuration de notre filesystem.
- Si l'interface est directement connectée à votre fonctionnalité en cours de test (comme dans le cas présent - vous appelez directement dans le système de fichiers), rendez le code testable en ajoutant un niveau d'abstraction cachant l'interface. Dans notre exemple, déplacer l'appel direct au filesystem vers une classe séparée (telle que FileExtensionManager) serait une façon d'ajouter un niveau d'abstraction.
- Remplacer l'implémentation sous-jacente de cette interface interactive par un élément sur lequel vous avez un contrôle. Dans ce cas, vous remplacerez l'instance de la classe que votre méthode appelle (FileExtensionManager) par une classe de stub que vous pouvez contrôler (StubFileExtensionManager), donnant à votre code de test le contrôle des dépendances externes.
Ces étapes rendront notre programme en accord avec le D de SOLID à savoir qu'il dépendra d'une abstraction et non d'une implémentation (voir schéma ci-dessous) :
Refactoring et seam :
Le Refactoring est l'acte de changer un code sans en modifier la fonctionnalité. C'est-à-dire qu'elle fait exactement le même travail qu'auparavant. Ni plus, ni moins. Il est simplement différent. Un exemple de refactoring pourrait être de renommer une méthode et de diviser une longue méthode en plusieurs méthodes plus petites.
Les Seams (ou coutures) sont des endroits dans votre code où vous pouvez brancher différentes fonctionnalités, telles que les classes stubées, l'ajout d'un paramètre de constructeur, l'ajout d'une propriété publique paramétrable, la virtualisation d'une méthode afin qu'elle puisse être écrasée, ou l'externalisation d'un délégué en tant que paramètre ou propriété afin qu'il puisse être défini depuis l'extérieur d'une classe. Les Seams sont ce que vous obtenez en mettant en œuvre le principe d'ouverture et de fermeture, selon laquelle la fonctionnalité d'une classe est ouverte pour l'extension, mais son code source est fermé pour la modification directe.
Les différentes coutures (seams) que nous allons mettre en place :
- Extrait d'une interface permettant de remplacer l'implémentation concrète.
- Injection d'une implémentation dans une classe en cours de test.
- Injection d'un stub au niveau d'un constructeur.
- Injection d'un stub au niveau d'un setter.
- Injection d'un stub juste avant l'appel d'une méthode.
Mise en place de l'interface :
Cette interface est notre contrat entre notre filesystem et notre logique métier.
Notre classe concrète permettant de récupérer le nom du fichier à valider.
Maintenant notre programme dépend d'une interface, mais en l'état, il n'est toujours pas testable.
Création du stub :
Il est possible entre autres de l'implémenter de deux manières, la première en ajoutant une méthode qui permet de setter la valeur de retour ou injecter cette valeur au constructeur.
Méthode avec un setter (c'est cette méthode que nous utiliserons pour nos coutures (seams) :
Méthode par injection au constructeur :
Injection du stub à l'aide du constructeur :
Injectons notre interface au constructeur de LogAnalyzer.
Mettons à jour nos tests en y injectant notre stub dans le constructeur de notre classe à tester.
À l'aide de notre méthode setFileName(String fileName) implémentée dans notre stub, nous pouvons simuler n'importe quel nom de fichier afin de satisfaire nos tests.
Remarque : cette méthode (setFileName(String fileName)) ne doit pas faire partie de notre contrat tant que celle-ci n'a pas d'utilité dans notre code de production.
Remarques de l'auteur avec l'injection au constructeur :
"Des problèmes peuvent surgir du fait de l'utilisation de constructeurs pour injecter des stubs. Si votre code testé nécessite plus d'un stub pour fonctionner correctement sans dépendances, l'ajout de plus en plus de constructeurs (ou de plus en plus de paramètres de constructeur) devient un problème, et cela peut même rendre le code moins lisible et moins facile à maintenir."
Deux solutions pour y remédier :
- Une solution consiste à créer une classe spéciale qui contient toutes les valeurs nécessaires pour initialiser une classe et à n'avoir qu'un seul paramètre à la méthode. De cette façon, vous ne faites circuler qu'un seul objet avec toutes les dépendances pertinentes (c'est ce qu'on appelle le refactoring d'objets paramétrés.). Cela peut dégénérer assez rapidement, avec des dizaines de propriétés sur un objet, mais c'est possible.
- Une autre solution possible consiste à utiliser des conteneurs d'inversion de contrôle (IoC). Vous pouvez considérer les conteneurs IoC comme des "smart factories" pour vos objets (bien qu'ils soient beaucoup plus que cela).
Malgré ces remarques, l'auteur dit préférer cette méthode aux autres, car, en terme de lisibilité et de compréhension d'une API, c'est la meilleure.
Injection d'un stub a l'aide du setter :
Votre code de test serait assez similaire à celui de la section précédente, qui utilisait l'injection au constructeur. Mais ce code, illustré ci-dessous, est plus lisible et plus simple à écrire.
Par défaut, la dépendance initialisée est celle de notre implémentation concrète, tandis qu'on défini une méthode pour setter cette dépendance à notre guise.
Mettons à jour nos tests en y injectant notre stub dans le setter prévu à cet effet.
Un des avantages d'utiliser cette méthode est que dans un contexte de code legacy vous évitez d'éventuel "effet de bords" en y ajoutant un constructeur ou en modifiant celui déjà en place.
Injection d'un stub juste avant l'appel d'une méthode :
Cette fois-ci c'est une classe d'usine (Factory) qui est en charge d'initialiser dans le constructeur notre classe FileExtensionManager, notre interface est toujours utilisée comme couche d'abstraction entre notre programme et le filesystem.
Il s'agit d'une conception propre, et de nombreux systèmes orientés objet utilisent des classes d'usines pour renvoyer des instances d'objets. Mais la plupart des systèmes ne permettent à personne en dehors de la classe d'usine de modifier l'instance renvoyée, afin de protéger la conception encapsulée de cette classe.
Voici à quoi ressemble notre factory, dans lequel j'ai ajouté une nouvelle méthode (une nouvelle couture / seam) à la classe d'usine afin que nos tests aient plus de contrôle sur l'instance qui sera retournée.
Mettons à jours nos tests.
Comme expliqué dans mes notes du chapitre 2, une des rares fois où il est logique d'utiliser une méthode Setup() dans les tests unitaires, c'est lorsque vous devez "réinitialiser" l'état d'une variable statique à l'exemple d'ExtensionManager.
La mise en œuvre de la classe d'usine peut varier considérablement, et l'exemple présenté ici ne représente que l'illustration la plus simple.
La seule chose dont vous devez vous assurer est qu'une fois que vous utilisez ces modèles, vous ajoutez une couture (seam) aux classes d'usines (fatories) que vous fabriquez afin qu'elles puissent vous renvoyer vos stubs au lieu des implémentations par défaut.
Différentes couches de code qui peuvent être simulées, et leurs actions :
- Profondeur de couche 1 : contexte : la variable FileExtensionManager à l'intérieur de la classe; actions : Ajoutez un argument de construction qui sera utilisé comme dépendance. Un membre de la classe testée peut désormais être stubé, tous les autres codes restent inchangés.
- Profondeur de couche 2 : contexte : la dépendance est retournée depuis la classe d'usine dans la classe testée; actions : Dites à la classe d'usine de vous retourner votre fausse dépendance (stub) en la modifiant depuis un setter. Le membre de la classe d'usine est une doublure, la classe testée n'est pas du tout modifiée.
- Profondeur de couche 3 : contexte : la classe usine qui retourne la dépendance; actions : Remplacez la classe de l'usine par une fausse usine qui vous retourne votre fausse dépendance (stub). L'usine est une doublure, qui renvoie également une doublure, la classe testée n'est pas modifiée.
Utilisation d'une méthode d'usine locale (extract et override) :
Ici nous avons à faire à une méthode qui fait office d'usine (factory).
Commençons par rendre protected notre méthode d'usine afin qu'elle soit accessible uniquement pour notre classe fille qui représentera notre doublure de LogAnalyzer.
La classe TestableLogAnalyzer "Override" la méthode d'usine de sa classe mère afin qu'elle puisse retourner le stub qu'on lui aura injecté dans le constructeur.
Nous pouvons maintenant utiliser cette nouvelle classe pour tester la fonctionnalité héritée de sa classe mère.
La technique utilisée ici s'appelle "Extract and Override", et vous la trouverez extrêmement facile à utiliser une fois que vous l'aurez fait plusieurs fois.
C'est une technique puissante, car elle permet de remplacer directement la dépendance sans changer les dépendances dans la pile d'appels.
Extract and Override est idéal pour simuler les entrées dans votre code testé, mais il est lourd lorsque vous voulez vérifier les interactions qui sortent du code testé dans vos dépendances.
La seule fois où l'auteur n'utilise pas cette technique, c'est quand la base de code montre clairement qu'il y a un chemin tracé pour lui, comme une interface prête à être stubée ou bien un endroit où on peut injecter une couture.
Utilisation "d'Extract and Override" pour créer de faux résultats :
Examinons une autre façon de contrôler le code testé sans utiliser d'interfaces.
L'interface n'étant plus présente, c'est l'implémentation concrète qui est initialisée dans une méthode chargée de récupérer le nom du fichier à valider.
Après avoir rendu protected la méthode que l'on veut fake, je la redéfinis dans une classe fille qui est implémentée comme notre stub implémentant l'interface ExtensionManager.
Aucune injection n'est nécessaire dans cette technique, il me suffit juste de redéfinir le nom du fichier à valider à l'aide de notre méthode prévue à cet effet. Nous pouvons maintenant utiliser cette nouvelle classe pour tester la fonctionnalité héritée de sa classe mère.
"Cette méthode est vraiment simple à mettre en place. Elle évite également d'éventuels "effets de bords" en évitant d'ajouter un constructeur et d'y injecter une dépendance. Cela peut être très utile en pleine phase de refactoring."
Avant de vous laisser, vérifions que nos tests passent toujours après ces multiples coutures placées ici et là ;)
Voilà, c'est tout pour cette partie. On se retrouve au CHAPITRE 4.
Fin...
Software Engineer
4 ansL'extract et l'override sur du legacy me parait vraiment très intéressant ! Un gros merci pour les efforts d'illustration qui sont très clair