Accueil Nos publications Blog La face cachée des tests unitaires

La face cachée des tests unitaires

Il y a quelques années sur l’un de mes premiers projets professionnels le chef de projet est venu me voir avec une phrase en apparence simple : “On va mettre des tests unitaires !”

Le sujet étant nouveau dans l’entreprise, j’ai commencé à m’autoformer. 3 jours plus tard je comitais dans la douleur de 2 tests qui n’avaient rien d’unitaires. Aujourd’hui, après quelques années de pratique de TDD, je reviens sur ce qui m’avais manqué et qui n’est pas développé dans les tutoriels sur les tests unitaires.

TLDR :

Pour mettre en place des tests unitaires il vous faut connaître quelques notions d’architecture logicielle : Single Responsability Principle, les niveaux d’abstraction, Dependency Inversion Principle et l’interface segratation principle. Tout cela permet de découper le code de façon testable. 

Cas d’exemple

Tout au long de cet article nous allons suivre une fonctionnalité fictive. Une entreprise de création de meubles pour professionnels a besoin d’une fonctionnalité de validation d’une commande. Pour être validée, la commande doit vérifier qu’il y a assez de produits en stock pour fabriquer la commande. Enfin, on vérifie que le client n’a pas déjà une commande en cours. Ici le code initial et non-testable de la fonctionnalité :

//Code

Nous pouvons détecter plusieurs vulnérabilités : tout est fait dans le contrôleur, le code est rigide car on instancie directement les classes nécessaires et quand ce n’est pas le cas on se retrouve bloqué avec un singleton (cas du repository). Nous verrons comment faire évoluer ce code pour le rendre testable…

La difficulté du legacy

On trouve beaucoup d’articles qui expliquent ce que sont les tests unitaires, leur utilité et un exemple simple de mise en place. Cependant la plus grosse difficulté est de passer du tuto simplifié à la réalité du code Legacy. Il y a plusieurs notions qu’il faut au moins connaître pour y arriver, mais en priorité dans toutes refactorisations, on veut s’assurer qu’on ne détériore pas la fonctionnalité. Comment faire pour mettre en place des tests tout en conservant le comportement de l’application ? Voici le test “Black box”.

Le test black box

Le test black box consiste à considérer votre système comme une boite noire. On ne cherche pas à comprendre ce qui se passe à l’intérieur et on ne s’intéresse qu’aux entrées et sorties. Dans le cas d’une API, ils peuvent être effectués avec un outils tels que Postman. On va créer des requêtes pour lesquelles on cherchera à avoir toujours la même réponse tout en essayant de couvrir les cas d’utilisation les plus significatifs. Dans cette typologie de test macro, il est donc judicieux d’avoir un avis métier.

Une fois qu’on s’est assuré du fonctionnement générique, on peut renter dans la boite et modifier l’organisation du code pour le rendre “testable”. La première notion à comprendre et appliquer est : Single Responsability Principle.

Single Responsability Principle

Every class, module, or function should have one responsibility/reason to change

Pour avoir un test unitaire simple qui va tester un cas précis, il faut que la méthode testée ne face qu’une seule chose. Si ce n’est pas le cas, vous vous retrouvez à initialiser beaucoup de paramètres qui ne vous servent à rien pour ce cas de test. C’est généralement un bon code smell pour les bloater ou les large class.

Respecter le SRP et définir ce que la méthode doit faire, cela permet entre autre de simplifier l’initialisation et la validation du test. De plus, le nombre réduit de paramètres vous permet d’entrevoir facilement plusieurs autres cas à tester.

Enfin, définir la responsabilité d’une méthode vous pousse à factoriser du code. Pour cela, il vous faut comprendre les niveaux d’abstraction.

Les niveaux d’abstraction

L’abstraction est l’un des fondamentaux de la programmation objet. Elle permet de réduire le code à un niveau de détails souhaité. En fonction de l’architecture du projet, chaque classe n’aura pas le même niveau d’abstraction.

Une classe de type Manager/Process/userStory a pour responsabilité la bonne cinématique d’appel (orchestration). Une factory a pour tâche de sortir le bon objet. Toutes les deux sont testables mais quand la première va s’attacher à bien suivre un processus la seconde va traiter des aspects uniquement techniques. Elles sont toutes les deux à des niveaux d’abstraction différents et donc à tester séparément.

Cas d’exemple

Nous avons maintenant séparé le processus de validation en plusieurs classes aux responsabilités différentes.

Le contrôleur ne gère plus que les entrées et les sorties.

ValidateCommandProcess a pour charge de suivre le processus de validation, chaque sous-tâche est déléguée à une autre classe.

Ainsi la classe CommandPriceCalculator s’occupe du calcul du prix de la commande.

Le fait d’utiliser plusieurs niveaux d’abstraction nous force à répartir les responsabilités dans plusieurs classes, cela rend le code plus facilement maintenable, diviser pour mieux régner. Cependant ces classes s’appellent mutuellement. Elles sont donc fortement couplées. On va donc casser ces liaisons fortes grâce au Dependency Inversion Principle.

Dependency Inversion Principle

High level modules should not depend on low level modules; both should depend on abstractions

Dependency Inversion Principle nous indique qu’une classe de type process ne doit pas être contrainte par les instances des classes quelle appelle. Toute modification dans ces classes risque de casser le fonctionnement de la classe haute (high level). Pour s’assurer d’avoir le comportement souhaité on passe par des interfaces. On retrouve ici L’interface segregation principe. Il permet d’indiquer quels sont les comportements des classes que l’on vient de faire émerger tout en « attribuant » un titre à leur responsabilité. On met en quelque sorte un titre à leur responsabilité. Chaque responsabilité devient une interface qui est implémentée par une ou plusieurs classes. Notre classe process n’implémente plus que les interfaces qui lui sont utiles. Quelque soit la classe réellement instanciée, l’interface lui garantit le comportement de la classe instanciée.

ValidateCommandProcess prend maintenant en paramètre de son constructeur uniquement les interfaces dont elle a besoin. On remarque également que le code devient plus lisible et donc plus facile à comprendre.

Les tests unitaires

Maintenant nous avons un code testable. Chaque méthode n’a qu’un seul but. Les classes deviennent facilement testables car on peut faire des mock des interfaces utilisées et y injecter les informations souhaitées.

Conclusion

Les tests unitaires sont des outils extrêmement pratiques et sécurisants dans une application. On a vu ici que leur mise en place demande une bonne réparation du code. Les exemples fournis ici ont été volontairement simplifiés et des améliorations sont envisageables. Je conseille souvent de rendre son code testable car cela ouvre la voie à d’autres pratiques comme le TDD et le refactoring.