Publié par
Il y a 4 années · 8 minutes · Craft

Craftsman Recipes : Soignez vos tests unitaires

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 et forUserBirthYear 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.

5 thoughts on “Craftsman Recipes : Soignez vos tests unitaires”

  1. Publié par chris, Il y a 4 années

    Pourquoi ne pas utiliser jBehave à la place de FEST ?

    Given les artistes disponibles
    |nom|
    |Muse|
    |RadioHead|

    And l’utilisateur est né en 1968

    Then le système Rock lui propose
    |nom|
    |AC/DC|
    |Motorhead|

    public class MusicGuideSteps {
    private List artists;
    private String birthdate;

    @Given("les artistes disponibles $examples")
    public void rememberArtists(ExampleTable t) {
    this.artits = ExampleTableUtils.extractCol(t, "nom", String.class);
    }
    @Given("l'utilisateur est né en $birthDate")
    public void rememberBirthdate(String birthDate) {
    this.birthdate = birthdate;
    }
    @Then(le système Rock lui propose $examples)
    public void assertRock(ExampleTable t) {
    assertMusicGuide(t, new RockMusicGuide());
    }

    private void assertMusicGuide(ExampleTable t, MusicGuide mg) {
    List suggestions = mg.inLibrary(artists).withDate(birthdate).suggest();
    List expectedSuggestions = ExampleTableUtils.extractCol(t, "nom", String.class);
    Fest.compare(expectedSuggestions, suggestions);
    }
    }

    Après, l’exemple me semble assez mal choisi pour un test unitaire car dans la vraie vie il est probable que pour faire les recommandations on va utiliser une source de données externes (qui contient les données de tous les utilisateurs du système ou tous les artistes avec leurs dates) et ca me semble compliqué pour un TU…
    En faisant une fausse source de données on va s’en sortir mais est-ce l’objectif du test ?

    D’une manière plus générale, le BDD pour les tests unitaires est à mon avis peu utile mais très important pour les tests fonctionnels !
    En effet, il fait apparaitre un DSL fort utile et agréable à utiliser.

    Selon moi, un exemple de TU à base de BDD peut souvent s’exprimer par des tests paramétrés (ex: testé si un mail est valide / invalide pour raison X ou Y).
    Dans le cas MusicGuide, un « simple » tableau ferait l’affaire mais c’est moins beau que le BDD :

    new String[][]{
    new String[]{"Rock", "RollingStones,RadioHead", "1987", "Muse"}
    }

  2. Publié par Christophe Pelé, Il y a 4 années

    @chris Effectivement, JBehave est parfaitement adapté aux BDD mais il nécessite l’installation d’un plugin pour l’intégration dans l’IDE.

    Concernant le choix de l’exemple, l’utilisation d’une source de données externe n’empêche pas de passer par un test unitaire à condition de savoir simuler cette dépendance. L’intérêt est justement d’avoir une fausse source qui retourne une quantité de données limitée et facile à appréhender.

    Quant au choix du BDD pour les tests unitaires, ça dépend de quel « B » on parle, c’est à dire à qui appartient le comportement à tester. Si on ne s’intéresse qu’au comportement du système dans son ensemble, il vaut mieux faire des tests d’intégration, c’est exact.

    Si on s’intéresse au comportement d’un composant donné, ici RockMusicGuide (et c’est dans ce sens que va mon article), le test unitaire est suffisant, quitte à simuler les dépendances externes.

    Concernant les tests paramétriques, je ne suis pas contre et j’utilise généralement junitparams pour « blinder » mes tests avec des jeux de données supplémentaires. En revanche, je rédige toujours les premiers tests avec des données écrites en dur dans le corps du test car c’est plus facile à écrire.

  3. Publié par chris, Il y a 4 années

    jbehave ne nécessite pas de plugin dans l’IDE, on peut utiliser un simple test jUnit.
    L’intégration peut être utilisée pour avoir la complétion sur les steps mais c’est plutot un gadget (je dois avoir 1000+ scénarios jbehave dans mon projet actuel et je n’ai pas le plugin)

    Sinon, effectivemnt je n’avais songé que tu allais codé l’algo de suggestion, je pensais que c’était pour faire du mahout ou autre tecbno similaire.
    Dans la mesure où on va coder l’algo effectivemtn il s’agit d’un test unitaire.

  4. Publié par Clément HELIOU, Il y a 4 années

    Bonjour Christophe et merci pour cet article.

    Pour le découpage de mes tests, j’ai pour habitude de faire un ou plusieurs cas de tests par méthode publique.
    Ce que vous faites ici, si ce n’est sur les 2 méthodes de paramétrage. A ce propos, j’ai tendance à penser que les tests unitaires doivent tout tester, même le plus simple. Ce qui est simple aujourd’hui ne le sera peut être plus demain… Et si en plus, on commence à mettre des exceptions, on fragilise la règle établie. Notez que les accesseurs/mutateurs sont un peu à part puisqu’ils sont, la plupart du temps, utilisés par le code testé.

    Par ailleurs et de mon point de vue, votre nommage des méthodes, bien que très explicite fonctionnellement, a une faiblesse: on ne sait pas de quelle méthode on parle. Si la méthode nommée est bien testée, cela a quand même un minimum de sens. L’inconvénient étant bien évidemment que cela rallonge de le nom de la méthode.

    Merci encore pour cet article.
    Bien à vous.

Laisser un commentaire

Votre adresse de messagerie ne sera pas publiée. Les champs obligatoires sont indiqués avec *