La pratique des tests unitaires est maintenant bien acceptée dans les équipes de développement Java.
Malheureusement, le code de test reste moins soigné que le code de l’application, en particulier au niveau du nommage des classes et des méthodes de test. Difficile de maintenir une classe de test dont les méthodes sont nommées test1
, test2
et test3
… D’autant plus quand le développeur responsable a quitté l’équipe depuis plusieurs mois !
Cet article montre une démarche à suivre pour donner du sens à vos tests unitaires, en appliquant certains principes du Behavior Driven Development afin d’obtenir une classe de tests unitaires claire et maintenable. Les tests seront réalisés avec Junit.
Le composant à tester
Prenons l’exemple d’un système très rudimentaire de recommandation musicale que nous baptiserons Music Guide (MG). Ce système doit suggérer des artistes à partir de différentes caractéristiques fournies par l’utilisateur : le contenu de sa discothèque, son âge et sa nationalité.
C’est un composant dont le rôle est défini très précisément : sa responsabilité est de suggérer des artistes.
public interface MusicGuide { MusicGuide inLibrary(String... artists); MusicGuide forUserBirthYear(int year); List<String> suggest(); }
Principes de conception de l’interface :
- les méthodes
inLibrary
etforUserBirthYear
permettent de configurer le composant et d’indiquer les paramètres sur lesquels on se base pour les suggestions :inLibrary
spécifie une liste d’artistes de référence, sous la forme d’une liste d’arguments (à taille variable pour faciliter l’usage de la méthode),forUserBirthYear
donne l’année de naissance de l’utilisateur ;
- la méthode
suggest
retourne la liste des suggestions.
Les méthodes de configuration retournent un MusicGuide
afin de constituer une API fluide (fluent en anglais) qui permet de configurer et d’utiliser le composant en une seule instruction lisible, par exemple :
List<String> suggestions = new RockMusicGuide().inLibrary("radiohead", "muse", "ac/dc") .forUserBirthYear(1978) .suggest();
Dans la suite, nous testerons RockMusicGuide
(RMG), une implémentation spécialisée dans le rock.
L’approche standard
Commençons par l’approche usuelle, qui consiste à créer une méthode de test pour chaque méthode publique du composant, avec le préfixe test
. C’est ce que fait Eclipse
quand on lui demande de générer une classe de test automatiquement à partir du composant à tester (on se doute que la génération automatique n’est pas la meilleure façon d’écrire un test unitaire soigné pour votre code…)
public void RockMusicGuideTest { @Test public void testInLibrary() { } @Test public void testForUserBirthYear() { } @Test public void testSuggest() { } }
Ce découpage en méthodes de test n’est pas pertinent : les méthodes de configuration n’ont pas de comportement à tester. Nous pouvons supprimer testInLibrary
et testForUserBirthYear
.
En revanche, la méthode testSuggest
devra être déclinée pour chaque cas d’utilisation, par exemple :
public void RockMusicGuideTest { @Test public void testSuggestIfLibraryContainsRadioheadAlbums() { } @Test public void testSuggestIfUserIsBornInThe60s() { } }
L’approche BDD
Énoncer les comportements à tester
La première étape quand on veut tester une classe est de se demander ce qu’on va tester. Ici, au lieu de se focaliser sur la méthode à tester, nous allons nous intéresser à son comportement.
Voici donc la liste des comportement attendus :
- RMG doit suggérer Muse si la discothèque de l’utilisateur contient un album de radiohead ;
- il doit suggérer AC/DC si l’utilisateur est né dans les années 60.
Cette liste est traduisible directement en classe de test unitaire :
- le nom de la classe est le nom du composant avec le suffixe
Test
; - chaque fonctionnalité se traduit par une méthode de test préfixée par
should
qui énonce le comportement attendu.
Voici le squelette du test correspondants :
public class RockMusicGuideTest { @Test public void should_suggest_Muse_if_user_library_contains_Radiohead_albums() { } @Test public void should_suggest_ACDC_if_user_was_born_in_the_60s() { } }
Observations :
- on constate une entorse à la convention de nommage des méthodes java en camelCase. L’utilisation de l’underscore est plus lisible et permet de se rapprocher au maximum d’une phrase en langage humain.
L’inconvénient réside dans la longueur des noms de méthodes… Mais en contrepartie cela constitue un bon signal pour détecter une classe qui devient trop compliquée ; - la classe de test ne porte pas le nom de l’interface, mais celui de l’implémentation testée, puisque c’est elle qui porte le comportement à tester ;
- l’objectif de ce nommage est de pouvoir lire le test ainsi :
- rock music guide should suggest Muse if user library contains Radiohead albums,
- it should suggest ACDC if user was born in the sixties.
Tester les comportements
Rajoutons maintenant le code de test :
public class RockMusicGuideTest { @Test public void should_suggest_Muse_if_user_library_contains_Radiohead_albums() { List<String> suggestions = new RockMusicGuide() .inLibrary("Radiohead", "Biffy Clyro", "Rolling Stones") .suggest(); assertThat(suggestions).contains("Muse"); } @Test public void should_suggest_ACDC_if_user_is_born_in_the_60s() { List<String> suggestions = new RockMusicGuide() .inLibrary("Radiohead", "Biffy Clyro", "Rolling Stones", "Muse") .forUserBirthYear(1964) .suggest(); assertThat(suggestions).contains("AC/DC"); } }
Observations :
- il s’agit simplement d’exercer le code du MG en obtenant les suggestions par rapport à la bibliothèque actuelle de l’utilisateur et à sa date de naissance ;
- l’import statique de la méthode
Assertions.assertThat
fournie par les librairies FEST permet d’améliorer la lisibilité des assertions ; - la mise en place du deuxième test comporte pas mal de code en commun avec le premier test. En réalité, il concerne deux comportements différents de la méthode de suggestion (par année et par contenu de bibliothèque), ce qui n’est pas évident à la lecture du test. Nous allons y remédier.
Formuler plus précisément les comportements
Pour que le comportement à tester dans chaque méthode soit plus clairement identifié, nous allons structurer chaque test en 3 sections (GIVEN, WHEN, THEN). Le plus simple est d’insérer des commentaires :
public class RockMusicGuideTest { @Test public void should_suggest_Muse_if_user_library_contains_Radiohead_albums() { // GIVEN MusicGuide guide = new RockMusicGuide(); List<String> library = Arrays.asList("Radiohead", "Biffy Clyro", "Rolling Stones"); // WHEN List<String> suggestions = guide.inLibrary(library).suggest(); // THEN assertThat(suggestions).contains("Muse"); } @Test public void should_suggest_ACDC_if_user_is_born_in_the_60s() { // GIVEN MusicGuide guide = new RockMusicGuide() .inLibrary("Radiohead", "Biffy Clyro", "Rolling Stones", "Muse"); int birthYear = 1964; // WHEN List<String> suggestions = guide.forUserBirthYear(birthYear).suggest(); // THEN assertThat(suggestions).contains("AC/DC"); } }
Observations :
- la section GIVEN contient la mise en place du contexte d’exécution du test ;
- la section WHEN permet d’exercer un comportement précis du composant qui est testé : ici, la suggestion en fonction du contenu de la librairie dans le premier test, puis la suggestion en fonction de l’année de naissance dans le second ;
- la section THEN contient les vérifications concernant le résultat du test : assertions, vérification des appels aux mocks, etc ;
- pour séparer ces trois sections et alléger la notation, certains développeurs préfèrent utiliser des sauts de ligne ;
- l’intérêt de cette pratique est de focaliser le test en désignant précisément le comportement qui est testé, agissant ainsi comme un « mode d’emploi » du composant ;
- cela permet aussi de faciliter la paramétrisation des tests. Si on veut décliner le test pour plusieurs années de naissance, il est facile de créer un test paramétrique sur cette variable, par exemple grâce à junitparams ou à l’annotation
@Parameters
de Junit.
Conclusion
Voici ce que nous avons spécifié dans ce billet :
- l’interface d’un composant de recommandation musicale ;
- une liste de comportements attendus (dans la signature des méthodes de test) pour une implémentation spécifique rock ;
- le détail de chaque comportement, soigneusement expliqué sous forme de scénario BDD à l’intérieur des méthodes de test.
L’implémentation reste donc à faire (peut-être un prochain article) et nous respectons l’approche BDD, tout en conservant un outillage familier pour de nombreux développeurs java (FEST, Junit).
Dans Literate Programming, Donald Knuth (créateur de TeX) avait déjà exprimé tout l’enjeu de la démarche présentée dans cet article :
Je crois que le temps est venu pour une amélioration significative de la documentation des programmes, et que le meilleur moyen d’y arriver est de considérer les programmes comme des œuvres littéraires. D’où mon titre, « programmation lettrée ».
Nous devons changer notre attitude traditionnelle envers la construction des programmes : au lieu de considérer que notre tâche principale est de dire à un ordinateur ce qu’il doit faire, appliquons-nous plutôt à expliquer à des êtres humains ce que nous voulons que l’ordinateur fasse.
Le praticien de programmation lettrée peut être vu comme un essayiste, qui s’attache principalement à l’exposition du sujet et à l’excellence du style. Un tel auteur, le dictionnaire à la main, choisit avec soin les noms de ses variables et explique la signification de chacune. Il cherche à obtenir un programme qui est compréhensible parce que les concepts ont été présentés dans le meilleur ordre pour la compréhension humaine, en utilisant un mélange de méthodes formelles et informelles qui se complètent l’une l’autre.
L’application du BDD aux tests unitaires se rapproche du literate programming. Le code java devient un support de communication et vous devenez un « programmeur lettré » dont l’oeuvre n’est pas réalisable par un bête générateur de tests.