Écrire du code testable
Publié: 2022-11-03Les tests unitaires sont un instrument essentiel dans la boîte à outils de tout développeur de logiciels. L'écriture de tests unitaires est relativement facile lorsqu'il s'agit d'une base de code qui suit les meilleures pratiques, modèles et principes de conception de logiciels. Le vrai problème survient lorsque vous essayez de tester un code mal conçu et non testable.
Ce blog discutera de la façon d'écrire plus de code testable unitaire et des modèles et des mauvaises pratiques à éviter pour améliorer la testabilité.
Code testable et non testable
Lorsque nous travaillons sur des applications à grande échelle qui doivent être maintenables à long terme, nous devons compter sur des tests automatisés pour maintenir la qualité globale du système à un niveau élevé. Par rapport aux tests d'intégration, où vous testez plusieurs unités dans leur ensemble, les tests unitaires ont l'avantage d'être rapides et stables. Rapide car nous instancions, idéalement, uniquement la classe testée, et stable car nous simulons généralement les dépendances externes, par exemple, la base de données ou la connexion réseau.
Si vous ne connaissez pas la différence exacte entre les tests unitaires et d'intégration, vous pouvez en savoir plus sur ce sujet dans notre blog Introduction aux tests.
Le code testable peut être isolé du reste de notre base de code. En d'autres termes, les plus petites unités peuvent être testées indépendamment. Un code non testable est écrit de telle manière qu'il est difficile, voire impossible, d'écrire un bon test unitaire pour celui-ci.
Passons en revue quelques anti-modèles et mauvaises pratiques que nous devrions éviter lors de l'écriture de code testable.
Les exemples sont écrits en Java, mais les conventions de codage mentionnées ici s'appliquent à tout langage de programmation orienté objet et cadre de test. Nous utiliserons assertj et JUnit5 pour des exemples dans cet article de blog.
Injection de dépendance
L'injection de dépendances est l'un des modèles de conception les plus importants pour obtenir l'isolement des tests. L'injection de dépendances est un modèle de conception dans lequel un objet reçoit d'autres objets (dépendances) via des paramètres de constructeur ou des setters au lieu d'avoir à les construire lui-même.
Avec l'injection de dépendances, nous pouvons facilement isoler la classe testée en simulant les dépendances d'un objet.
Regardons un exemple sans injection de dépendance :

Étant donné que la dépendance au moteur est en cours de construction dans le constructeur de la classe Car, vous pouvez dire que les classes Car et Engine sont étroitement couplées. Ils sont très dépendants les uns des autres - changer l'un nécessiterait un changement de l'autre.
Du point de vue des tests, vous ne pouvez pas tester la classe Car de manière isolée car l'exemple ci-dessus ne peut pas remplacer l'implémentation concrète du moteur par un test double.
Cependant, nous pouvons réaliser l'isolation avec l'utilisation de l'injection de dépendance et du polymorphisme :

Nous pouvons maintenant construire plusieurs implémentations de moteurs et, par conséquent, des voitures avec des moteurs différents :

Les tests isolés sont désormais possibles car nous pouvons créer une implémentation fictive de l'abstraction Engine et la transmettre à notre classe Car :

Lorsque vous traitez avec des objets qui nécessitent d'autres objets (dépendances), vous devez les fournir via des paramètres de constructeur (injection de dépendances), idéalement cachés derrière une abstraction.
En suivant ce modèle, votre code devient plus lisible et adaptable aux changements au fil du temps. En outre, vous devez éviter de faire du travail réel dans les constructeurs - tout ce qui dépasse les affectations sur le terrain est un travail réel. Le mot clé `new` dans les constructeurs est toujours un signe avant-coureur d'un code non testable.
Une chose à noter ici est que dans certaines situations, le couplage étroit (par exemple, de nouveaux mots clés dans les constructeurs, des classes internes pour l'isolation logique, des mappeurs d'objets) n'est pas une mauvaise pratique - des classes qui n'auraient pas de sens en tant que classe « autonome ».
État global
Le partage d'un état global peut souvent produire des tests aléatoires (parfois réussis, parfois échoués), en particulier dans les environnements multithreads.
Imaginez un scénario dans lequel plusieurs objets testés partagent le même état global - si une méthode dans l'un des objets déclenche un effet secondaire qui modifie la valeur de l'état global partagé, la sortie d'une méthode dans l'autre objet devient imprévisible. Évitez d'utiliser des méthodes statiques impures car elles modifient l'état global d'une manière ou d'une autre ou sont associées à un état global.

Regardons cette méthode statique impure :

Essentiellement, cette méthode lit la date et l'heure actuelles du système et renvoie un résultat basé sur cette valeur. Il serait très difficile d'écrire un test unitaire basé sur l'état pour cette méthode car l'appel statique LocalDateTime.now() produira des résultats différents lors de l'exécution de nos tests. L'écriture de tests pour cette méthode est impossible sans changer la date et l'heure du système.
Pour résoudre ce problème, nous allons passer la méthode date time to timeOfDay en argument :

La méthode statique timeOfDay est maintenant pure - les mêmes entrées produisent toujours les mêmes résultats. Maintenant, nous pouvons facilement passer des objets dateTime isolés comme arguments dans nos tests :

Loi de Déméter
La loi de Déméter, ou le principe de moindre connaissance, stipule que les objets ne doivent connaître que les objets étroitement liés au premier objet. En d'autres termes, un objet ne doit avoir accès qu'aux objets dont il a besoin. Par exemple, nous avons une méthode qui accepte un objet contextuel comme argument :

Cette méthode viole la loi de Déméter car elle doit parcourir un graphe d'objets pour obtenir les informations requises pour faire son travail. Passer des informations inutiles dans les classes et les méthodes nuit à la testabilité.
Imaginez un énorme objet BillingContext contenant des références à d'autres objets :

Comme nous pouvons le voir, notre test est gonflé d'informations non essentielles. Les tests créant des graphes d'objets complexes sont difficiles à lire et introduisent une complexité inutile.
Corrigeons notre exemple précédent :

Vous devez toujours passer des dépendances directes dans vos classes et méthodes. Cependant, passer de nombreux arguments dans des méthodes n'est pas non plus une bonne pratique - idéalement, vous devriez passer deux arguments au maximum ou encapsuler des arguments proches dans des objets de données.
Objets de Dieu
L'objet Dieu est un objet qui fait référence à de nombreux autres objets distincts, a plus d'une responsabilité et a de multiples raisons de changer. S'il est difficile de résumer ce que fait la classe, ou si résumer comprend le mot « et », la classe a probablement plus d'une responsabilité.
Les objets divins sont difficiles à tester car nous avons affaire à de multiples dépendances sans rapport, mélangeant différents niveaux d'abstractions et de préoccupations, et ils produisent beaucoup d'effets secondaires. Par conséquent, il est difficile d'atteindre l'état souhaité pour nos cas de test.
Par exemple:

UserService a plus d'une responsabilité - enregistrer de nouveaux utilisateurs et envoyer des e-mails. Lors du test d'enregistrement des utilisateurs, nous devons gérer le service de messagerie et vice versa :

Imaginez un UserService avec plus de deux dépendances non liées. Ces dépendances ont leurs propres dépendances, et ainsi de suite. Nous nous retrouverions avec un test illisible, bourré d'informations sans rapport et très difficile à comprendre. Par conséquent, chaque classe ne devrait avoir qu'une seule responsabilité et raison de changer. Une classe n'ayant qu'une seule raison de changer est l'un des cinq principes de conception de logiciels appelés le principe de responsabilité unique.
Vous pouvez en savoir plus sur les principes SOLID ici.
Conclusion
Une base de code qui suit les meilleures pratiques de conception de logiciels rend l'écriture de tests unitaires beaucoup plus facile à gérer.
D'un autre côté, il peut être très difficile, voire parfois impossible, d'écrire un test unitaire de travail pour une base de code en utilisant les anti-modèles mentionnés. Écrire un bon code testable nécessite beaucoup de pratique, de discipline et d'efforts supplémentaires. L'avantage le plus important du code testable est la facilité de testabilité et la capacité à comprendre, maintenir et étendre ce code.
Nous espérons que ce blog vous aidera à écrire du code testable.