Ces dernières années, on parle beaucoup du Test Driven Development, aussi connu sous l’acronyme TDD. Ce dernier s’inscrit en fait dans une démarche d’amélioration de la qualité des applications. En effet, les tests doivent normalement être écrits avant le code et doivent commencer par échouer. Sinon, le seul intérêt des tests unitaires sera de garantir une non régression par rapport au code actuel. Autre avantage des tests unitaires : pour que votre code soit testable il faut qu’il soit un minimum bien construit, sinon l’écriture des tests devient très complexe. James Carr a d’ailleurs publié un catalogue d’anti-patterns sur le sujet.
Débloque les + belles offres tech en 10 mins
La première chose qui compte est la couverture de code de votre application par les tests unitaires. On considère qu’une application est correctement couvert par les tests unitaires dès lors que 80% du code au minimum est couvert, autrement dit qu’en exécutant vos tests unitaires on passe par 80% des lignes de votre code.
Considérant le programme suivant :
public String testAB(final boolean a, final boolean b) {
if (a && b) {
return "a et b sont vrais";
} else {
return "a ou b est faux";
}
}
Si on veut tester correctement cette méthode, il faut envisager tous les cas, à savoir :
Ici l’exemple est trivial, mais il faut quand même reconnaître que dans la vraie vie ce genre de tests peut représenter un travail énorme, d’autant que comme on peut le voir on teste en fait trois fois le même chemin de code. Dans la suite de l’article, on voit justement comment éviter ça.
Un autre cas qui peut être pire est celui où votre méthode appelle des méthodes d’autres objets. Dans ce cas, afin d’éviter de devoir tester les autres objets n fois, on fait appel à des mock objects, tels que ceux fournis par la librairie Mockito. Le principe est simple : les mock objects ont la même API que les objets réels que votre classe appelle, et dans le test vous vous contentez de spécifier comment votre mock doit réagir à un appel avec des paramètres donnés. Après, il suffit d’injecter ces mocks dans votre classe à tester pour faire le travail.
En Java, nous avons quatre niveaux possible de visibilité des méthodes et des variables membres :
Seuls les éléments public et protected font partie de votre API et doivent garantir un contrat. Les éléments private et avec la visibilité par défaut sont considérés comme des détails d’implémentation. Nous nous servons d’ailleurs de ce dernier point.
Afin d’éviter de dupliquer du code, on est souvent amené à écrire des méthodes private. Le problème de ces méthodes est qu’elles ne sont pas visibles en dehors de la classe. Aussi, si vous voulez les tester, le seul moyen est de tester en même temps le code appelant. A l’inverse, si votre méthode privée est appelée par plusieurs autres méthodes de votre classe, vous allez être amené à la tester plusieurs fois.
Dans tous les cas, il ne s’agit pas d’une méthode satisfaisante, et on se retrouve assez vite dans le cas de l’exemple de code ci-dessus avec trois cas de tests différents qui testent en fait le même chemin de code. L’idéal serait donc de pouvoir d’un côté tester la méthode privée, et de l’autre les méthodes y faisant appel en mockant la méthode privée.
Nous avons vu tout à l’heure que les classes avec visibilité par défaut ne sont pas visibles de l’extérieur. Dès lors, une solution permettant de résoudre d’un coup tous vos problèmes est tout simplement de déporter les méthodes privées de votre classe de base vers une classe compagnon, qui aura une visibilité par défaut, de même que ses méthodes. Ensuite vous testez individuellement ces méthodes, et au niveau de la classe de base vous n’avez plus qu’à créer un mock de la classe compagnon.
Par extension, si vous avez d’autres méthodes privées un temps soit peu complexes appelées par plusieurs méthodes privées que vous avez déportées dans votre classe compagnon, vous pouvez créer une deuxième classe compagnon qui sera un compagnon du premier compagnon, et ainsi de suite.
N’hésitez pas non plus à utiliser cette astuce si vous avez une méthode longue, afin de la découper en morceaux plus simples !
On peut aussi avoir des méthodes qui ont des conditions multiples dans un branchement, comme ci-dessous :
public String testConditions(final int a, final int b, final int c) {
if (a > 50 && b < 10 && c == 5) {
return "a et b sont vrais";
} else {
return "a ou b est faux";
}
}
Là encore, on se retrouve à devoir écrire plusieurs tests pour le même chemin de code. La solution est donc de déporter l’évaluation de la condition dans une méthode de la classe compagnon, qu’on teste d’un côté et de l’autre on n’écrit plus qu’un test dans le cas où la condition est vraie, et un autre si elle est fausse. Ca donne, dans la classe compagnon :
boolean checkCondition(int a, int b, int c) {
return a > 50 && b < 10 && c == 5;
}
Et au niveau de la classe de base :
public String testConditions(final int a, final int b, final int c) {
if (monCompagnon.checkCondition(a, b, c)) {
return "a et b sont vrais";
} else {
return "a ou b est faux";
}
}
Au final, on n’aura pas écrit moins de tests, mais par contre ceux-ci seront nettement plus simples.
Bien souvent, surtout avec des frameworks comme Spring, on sépare le code entre d’un côté les objets de données et de l’autre le code de traitement. En soi, c’est très bien, sauf qu’avec l’avènement de solutions telles qu’Hibernate ceux-ci se sont réduits à un ensemble de getters et de setters, ce qui est très mal.
Dans notre cas, c’est mal tout simplement parce que pour initialiser un objet, on se retrouve à écrire à peu près ça :
MyObject obj = new MyObject();
obj.setA(a);
obj.setB(b);
obj.setC(c);
...
C’est très gênant pour des tests unitaires car on doit dupliquer ce code ! Alors que si on avait écrit un constructeur qui prenait les bons paramètres d’un coup, cette initialisation aurait pu être écrite d’un coup et d’un seul ! Dans le cas où vous devriez avoir plusieurs initialisations possibles de votre objet, plutôt que d’écrire plusieurs constructeurs, vous pouvez passer par le pattern builder présenté dans l’excellent livre Effective Java de Joshua Bloch. Dans la même veine, c’est à votre objet de stockage de données de contrôler son état et non pas à vos services. Cela signifie que si plusieurs paramètres doivent être définis d’un coup pour assurer la cohérence de votre objet, ce dernier doit fournir une méthode permettant de les positionner d’un coup et surtout de les contrôler. Enfin, ceci permettra de séparer les responsabilités, et de tester d’un côté votre objet et de l’autre votre service d’une manière très simple.
Et dernier mot pour les frameworks du type Hibernate, configurez-les pour qu’ils aillent regarder les champs de vos classes plutôt qu’appeler les getters et setters. Ceci se fait généralement par annotation. Quant à la supposée différence de performances entre l’accès par méthodes et l’accès par champ, elle n’est pas vraie au moins pour les dernières JVMs.
De manière générale, l’héritage ne doit être utilisé que pour faire du polymorphisme et pour rien d’autre. Cela se vérifie de nouveau dans le cas des tests unitaires. En effet, appeler depuis une classe fille une méthode de la classe parente revient à se retrouver dans le même cas que celui des méthodes privées présenté ci-dessus. Sauf que si vous avez beaucoup de classes qui appellent une méthode de la classe parente, le souci se trouve multiplié par 10, 100 ou 1000. Donc au risque de me répéter, utilisez l’agrégation pour réutiliser vos méthodes, et cantonnez l’héritage aux stricts cas de polymorphisme. Cet article parle du problème.
On peut néanmoins utiliser l’héritage avec des tests unitaires en respectant les conditions suivantes :
A noter toutefois qu’hériter d’une ou plusieurs interfaces est totalement différent, étant donné que les interfaces ne servent que pour le polymorphisme.
Débloque les + belles offres tech en 10 mins
Cet article vous a plu ? Vous aimerez sûrement aussi :
Julien
Moi c’est Julien, ingénieur en informatique avec quelques années d’expérience. Je suis tombé dans la marmite étant petit, mon père avait acheté un Apple – avant même ma naissance (oui ça date !). Et maintenant je me passionne essentiellement pour tout ce qui est du monde Java et du système, les OS open source en particulier.
Au quotidien, je suis devops, bref je fais du dév, je discute avec les opérationnels, et je fais du conseil auprès des clients.
Les maladies inflammatoires chroniques de l’intestin ou "MICI" sont invisibles, mais leurs impacts sur la…
Depuis l'été, j'ai un Pixel qui intègre à la fois un TPU (Tensor Processing Unit)…
On se retrouve dans un nouvel article avec toutes les infos sur cette nouvelle saison…
Pourquoi l’inclusion numérique est essentielle : le point avec Mathieu Froidure. Dans un monde de…
Elles sont passées où les femmes dans la tech ? Entre le manque de représentation…