10 trucs infaillibles pour rater ses tests unitaires en toutes circonstances (1/2)

frog_shit_junitFaire des tests unitaires dans ses développements fait aujourd’hui partie des pratiques courantes, en particulier depuis l’avènement d’eXtreme Programming et des développements agiles…  Et pourtant, malgré la maturité de l’outillage dont on dispose aujourd’hui (Junit, TestNG, Mockito, JMockit…), qui ne s’est jamais retrouvé confronté en arrivant sur un projet legacy à la fameuse ritournelle  : « Les TU ? on les a désactivés, on n’arrivait plus à les faire marcher ! ». Alors, pas si facile les tests unitaires ? Écrire des TU est une chose, les pérenniser en est une autre… Voici 10 anti-practices à ne pas suivre, tirées d’expériences réelles, qui pourraient précipiter vos tests unitaires dans les oubliettes… Cet article est en 2 parties. Dans ce premier « post », je mettrai avant tout l’accent sur des « gestes techniques » et « recettes  » consensuelles, qui vous permettront d’éviter certains écueils rencontrés dans l’écriture des tests unitaires, et vous aideront pour leur maintenance. Je présenterai dans un prochain « post » d’autres difficultés et solutions (parfois plus subversives), que l’on sera également amené à rencontrer…

1. Écrire du code imbitable dans vos classes de test

Voici l’histoire de la recette du poulet au whisky, à la sauce XML… Laquelle de ces 2 versions allez-vous préférer ?

Version 1

public class RecetteTest {

    @Test
    public void testToXml() {
        Recette r = new Recette('poulet au whisky');
        assertNotNull(r);
        String s = r.toXml();
        assertNotNull(s);
        String[] splits = s.split('\n');
        assertTrue(splits.length == 8);
        for (String split : splits) {
            if (split == null || ''.equals(split)) {
                fail('error');
            }
        }
        assertEquals('<!--?xml version=\'1.0\' encoding=\'UTF-8\'?-->').trim(), splits[0].trim());
        assertEquals(''.trim(), splits[1].trim());
        assertEquals('\t'.trim(), splits[2].trim());
        assertEquals('\t\tpoulet'.trim(), splits[3].trim());
        assertEquals('\t\tolives'.trim(), splits[4].trim());
        assertEquals('\t\twhisky'.trim(), splits[5].trim());
        assertEquals('\t'.trim(), splits[6].trim());
        assertEquals(''.trim(), splits[7].trim());
    }
}
 

Version 2

public class RecetteTest {

    @Test
    public void testToXml() {
        // execute XML marshalling
        Recette recette = new Recette('poulet au whisky');
        String actualXml = recette.toXml();

        // read exepected result
        String expectedXml = readFileFromClasspath('expected_poulet-au-whisky.xml');

        // check result
        assertEquals(formatXml(expectedXml), formatXml(actualXml));
    }

    /**
    * read into a String a text file from classpath
    *
    * @param filePath
    * @return the String content
    */
    protected static String readFileFromClasspath(String filePath) {
        // ...
    }

    /**
    * @param unformatedXml
    * @return a formated and indented XML
    */
    protected static String formatXml(String unformatedXml) {
        // ...
    }
}

Écrire des méthodes trop longues, exploser la complexité cyclomatique, appliquer des  non-conventions de nommage, éviter soigneusement les commentaires dans le code, écrire en « ASCII art », pratiquer la duplication de code … Toutes ces pratiques malheureusement trop repandues sont autant de raisons de ne pas avoir envie de maintenir un code ou un test legacy… Un test qui ne marche plus et que personne ne veut pas réparer est à l’agonie. Ce n’est qu’une question de temps avant que plus personne ne puisse le remettre en état. Soyez donc « fourmi » plutôt que «cigale » quand vous écrivez vos tests ! Pensez à leur avenir, à la personne qui devrait les relire, les comprendre, les mettre à jour, les corriger. Toutes les bonnes pratiques de qualité de code valables sur votre application le sont également sur les tests :

  • Penser à la lisibilité et à la clarté de votre test. Éviter toute ambiguïté ! Un test est une histoire qu’on doit pouvoir rapidement comprendre
  • Le plus simple est toujours le mieux, Keep It Simple and Stupid ! Évitez d’alourdir le code du test avec des assert inutiles, ou des bouts de code morts

2. Avoir des tests dépendants du temps

Un bon test doit être au maximum isolé du monde extérieur, ne pas dépendre de paramètres que l’on ne maîtrise pas dans son test. Écrire un test dont le comportement dépend de la date à laquelle il s’exécute peut avoir de fâcheuses conséquences ; ça n’ira pas jusqu’à une rupture du continuum espace-temps provoquant la destruction totale de l’univers, mais cela mettra bel et bien vos tests en porte-à-faux ! Pour illustrer cette idée, dans l’exemple suivant, que se passera-t-il s’il on exécute le test le 22 octobre 2015 ?

public class Dolorean {

    public String startTemporalConvector(Date destinationDate) {
        if (destinationDate.after(new Date())) {
            return 'future';
        } else {
            return 'past';
        }
    }
}

public class DoloreanTest {

    @Test
    public void testTravelInPast() throws ParseException {
        Date psteDate = new SimpleDateFormat('dd/mm/yyyy').parse('12/11/1955');
        String destination = new Dolorean().startTemporalConvector(psteDate);
        assertEquals('past', destination);
    }

    @Test
    public void testTravelInFuture() throws ParseException {
        Date futureDate = new SimpleDateFormat('dd/mm/yyyy').parse('21/10/2015');
        String destination = new Dolorean().startTemporalConvector(futureDate);
        assertEquals('future', destination);
    }
}

… Le test testTravelInFuture() qui était vert jusqu’à présent passera rouge ! Pour rendre ce test insensible au temps qui passe, il faut casser cette dépendance à la date système. Il existe plusieurs façons de faire, mais le principe reste le même :

Solution 1 (à la main)

Notre code métier doit s’abstraire de la récupération de la date système. Nous devons être capables d’utiliser la date système dans le cas par défaut, et dans le contexte d’un test, d’utiliser une date fixe. On pourra implémenter ce comportement en passant par exemple par une Factory :

public class Dolorean {

    public String startTemporalConvector(Date destinationDate) {
        // pivot date is now configurable !
        if (destinationDate.after(DateFactory.getNow())) {
            return 'future';
        } else {
            return 'past';
        }
    }
}

public interface DateProvider {
    Date create();
}

public class DateFactory {

    public static DateProvider impl = new DateProvider() {
        @Override
        public Date create() {
            return new Date();
        }
    };

    public static Date getNow() {
        return impl.create();
    }
}

public class DoloreanTest {

    @BeforeClass
    public static void setupNow() {
        // set now = 1/1/2000
        DateFactory.impl = new DateProvider() {
            @Override
            public Date create() {
                try {
                    return new SimpleDateFormat('dd/mm/yyyy').parse('1/1/2000');
                } catch (ParseException e) {
                    return null;
                }
            }
        };
    }

    @Test
    public void testTravelInPast() throws ParseException {
        Date psteDate = new SimpleDateFormat('dd/mm/yyyy').parse('12/11/1955');
        String destination = new Dolorean().startTemporalConvector(psteDate);
        assertEquals('past', destination);
    }

    @Test
    public void testTravelInFuture() throws ParseException {
        Date futureDate = new SimpleDateFormat('dd/mm/yyyy').parse('21/10/2015');
        String destination = new Dolorean().startTemporalConvector(futureDate);
        assertEquals('future', destination);
    }

}

Dans le contexte de l’application, la date retournée par getNow() sera la date système, dans le test, ce sera le 1/1/2000 à chaque fois… NB : On pourrait par ailleurs utiliser un framework pour gérer les dates, tel que Joda time qui propose également de mocker la date système !

Solution 2 (avec un framework de mock)

Une autre solution élégante, en mockant avec Mockito

public class Dolorean {

    public String startTemporalConvector(Date destinationDate) {
        // pivot date is now configurable !
        if (destinationDate.after(now())) {
            return 'future';
        } else {
            return 'past';
        }
    }

    Date now() {
        return new Date();
    }

}

@RunWith(MockitoJUnitRunner.class)
public class DoloreanTest {

    private static Dolorean dolorean;

    @BeforeClass
    public static void setupNow() throws ParseException {
        // setup mocked now() method on a dolorean
        dolorean = Mockito.mock(Dolorean.class);
        Date firstJanuary2000 = new SimpleDateFormat('dd/mm/yyyy').parse('1/1/2000');
        Mockito.when(dolorean.now()).thenReturn(firstJanuary2000);
        // do not mock startTemporalConvector()
        Mockito.when(dolorean.startTemporalConvector(Mockito.any(Date.class))).thenCallRealMethod();
    }

    @Test
    public void testTravelInPast() throws ParseException {
        Date psteDate = new SimpleDateFormat('dd/mm/yyyy').parse('12/11/1955');
        String destination = dolorean.startTemporalConvector(psteDate);
        assertEquals('past', destination);
    }

    @Test
    public void testTravelInFuture() throws ParseException {
        Date futureDate = new SimpleDateFormat('dd/mm/yyyy').parse('21/10/2015');
        String destination = dolorean.startTemporalConvector(futureDate);
        assertEquals('future', destination);
    }

}

La cerise sur le gâteau

Nous venons de voir que mocker la date système dans vos tests vous permet de les rendre indépendants de la date de lancement (ce qui les rend pérennes) ; avantage supplémentaire de ce type de mock : nous pouvons à présent tester un 3ème cas de test intéressant : le cas aux limites destinationDate = today ! (Et constater ainsi qu’il reste un bug sur ce cas, où on souhaiterait ne pas démarrer inutilement le convecteur temporel ! Au prix où est le plutonium cette année…)

3. Négliger la vraie sémantique des types utilisés

Quand on est concentré sur l’écriture du code d’une fonctionnalité, on a parfois tendance à négliger l’implémentation de son test, et à s’autoriser quelques libertés sur du code qui n’est « que » du code de test… Certains raccourcis pourraient pourtant bien s’avérer fatals pour vos tests, dans un futur plus ou moins proche ! On aura, par exemple, tendance à oublier la sémantique des types que l’on manipule. Prenons le cas de test suivant :

public class Recette {

    private String nom;

    public Recette(String nom) {
        super();
        this.nom = nom;
    }

    public Set loadIngredients() {
        ...
    }

}

public class RecetteTest {

    @Test
    public void testLoadIngredients() {
        Recette recette = new Recette('poulet au whisky');
        Set ingredients = recette.loadIngredients();

        ArrayList ingredientsList = new ArrayList(ingredients);
        assertEquals('olives', ingredientsList.get(0));
        assertEquals('whisky', ingredientsList.get(1));
        assertEquals('poulet', ingredientsList.get(2));
    }

}

Le test fonctionne, tout est vert… Jusqu’au jour où arrive un projet de migration de l’application : il est temps d’abandonner java 5 au profit de java 7  ! Seulement voilà, avec la version 7 du JRE, les tests échouent avec l’erreur :

org.junit.ComparisonFailure: expected:<[olives]> but was:<[whisky]>
    at org.junit.Assert.assertEquals(Assert.java:123)
    at jre.RecetteTest.testLoadIngredients(RecetteTest.java:23)

Que s’est-il passé ? L’implémentation des assertions est depuis le début fausse !  En effet, les ingrédients chargés par loadIngredients()  sont retournés dans un java.util.Set, un type représentant un ensemble au sens mathématique, dans lequel il n’existe pas de doublon, et surtout, pas d’ordre sur les éléments ! (C’est ici notre erreur puisque notre test se base sur un supposé ordre pour vérifier le contenu du java.util.Set). Ainsi, le java.util.Set ne garantissant aucun ordre, les nouvelles implémentations java 7 pour java.util.Set (telles que java.util.HashSet)  ne retournent plus les éléments dans le même ordre que la version 5, ce qui reste sémantiquement correct mais a pour effet indésirable l’échec cuisant de notre test… Une implémentation correcte serait tout simplement :

public class RecetteTest {

    @Test
    public void testLoadIngredients() {
        Recette recette = new Recette('poulet au whisky');
        Set ingredients = recette.loadIngredients();

        Set expectedIngredients = new HashSet() {{
            add('olives');
            add('whisky');
            add('poulet');
        }};
        assertEquals(expectedIngredients, ingredients);
    }
}

NB : le problème rencontré ici n’a pas de lien direct avec les tests unitaires. La même faute d’inattention peut être commise dans le code applicatif, qui pourra être la cause d’un bug pas facile à détecter !

4. Jouer les gros (disques) durs

Parfois, il nous faut accéder au disque de la machine dans un test unitaire :

  • soit dans l’implémentation-même du  test, pour en améliorer la lisibilité (en listant par exemple depuis un fichier plat, un jeu de données d’entrée ; ou de la même façon, un résultat de méthode qui sert de référence (XML, json, CSV… )
    public class ECommerceServiceTest {
    
        ECommerceService service = new ECommerceService();
    
        @Test
        public void testFacturation() throws Exception {
            // input dataset read from file system
            String order = readFile('ma_commande1.csv');
    
            // run code
            String actualBill = service.createBill(order);
    
            // expected output read from file system
            String expectedBill = readFile('ma_facture1.csv');
    
            assertEquals(expectedBill, actualBill);
        }
    
    }
    
  • soit parce que le code testé génère lui-même un fichier d’output sur disque (ex : tester une classe qui produit un fichier de reporting sur disque)
    public class ECommerceService {
    
        List orderList = ...
    
        public void createOrdersReport(String outputFile) {
            FileWriter w = new FileWriter(new File(outputFile));
            w.write(toCsv(orderList));
            w.close();
        }
    
    }
    
    public class ECommerceServiceTest {
    
        ECommerceService service = new ECommerceService();
    
        @Test
        public void testCreateOrdersReport() throws Exception {
            // invoke service dumping output on file system
            String outputReport = 'orders_report.csv';
    
            // run code
            service.createOrdersReport(outputReport);
    
            // check result from file system
            String actualReport = readFile(outputReport);
            String expectedReport = ...
            assertEquals(expectedReport, actualReport);
        }
    }
    

Dans les 2 cas, on sera amené à résoudre les problèmes habituels liés aux fichiers sur disque…

Les problèmes classiques liés aux file systems

Qui dit tests unitaires, dit intégration continue et forge logicielle, et donc probablement hétérogénéité des environnements et file systems… Mieux vaut donc être compatible si l’on ne veut pas voir ses tests virer au rouge sur un environnement autre que son poste de développement !

Types de file system

Les chemins Windows et chemins Unix étant différents, évitez au maximum tout code potentiellement  plateform dependent :

File outputFile1 = new File('./work/out.txt');
// pointe vers ./work/out.txt sous unix, et .\work\out.txt sous windows

File outputFile2 = new File('.\\work\\out.txt');
// sous unix, pointe vers le ficher nommé 'work\out.txt' du répertoire courant !!

Encodage des fichiers

De la même façon, l’encodage d’un fichier texte varie d’un système à un autre. Le grand classique qu’on ne présente plus est le caractère “retour chariot” (‘\n’ sous unix et ‘\r\n’  sous windows).  Pour éviter toute mauvaise surprise, préférez le format Unix, car il est également correctement interprété par windows.

File system et bonnes pratiques dans les tests

Les chemins de l’enfer…

Les chemins (path) des fichiers utilisés dans vos tests sont également source d’erreur. Assurez-vous donc que les chemins de vos fichiers sont bien valides, sur n’importe quel environnement. Évitez autant que possible les chemins absolus, qui ne sont jamais les mêmes d’une machine à une autre. Utilisez toutes les ruses possibles et imaginables pour vous abstraire des chemins absolus :

  • chemins relatifs (simples, et efficaces… KISS !)
File inputFile = new File('./src/test/resources/montest1.txt');
  • A défaut, chemins absolus construits depuis une racine variabilisée, par exemple :
// pointe vers /tmp/out.txt sous unix, et c:\temp\out.txt sous windows (ou le temp\ de l'utilisateur)
File outputFile = new File(System.getProperty('java.io.tmpdir') + '/out.txt');

Assurez-vous que les chemins utilisés existent ! Le plus prudent est encore de créer les chemins à la volée quand ils n’existent pas…

public class TestReportService {

    private static String tmpDir = System.getProperty('java.io.tmpdir');
    private static String reportPath = '/report/';
    private static File reportDir = new File(tmpDir + reportPath);

    private ReportService reportService = new ReportService();

    @BeforeClass
    public static void setupPaths() {
        // create output dir if not exists
        if (!reportDir.exists()) {
            reportDir.mkdirs();
        }
    }

    @Test
    public void testMyReport() {
        String reportName = 'my_report';
        File generatedCsv = new File(tmpDir + reportPath + 'my_report.csv');
        reportService.report(reportName, generatedCsv);
        File expectedCsv = readFile('src/test/resources/expected_my_report.csv');
        assertCsvEquals(expectedCsv, generatedCsv);
    }

    private void assertCsvEquals(File expectedCsv, File generatedCsv) {
        // ...
    }

    private File readFile(String string) {
        //...
    }
}

Soyez propre et bien élevé

Par ailleurs, qui dit fichiers sur disque, dit quota… L’espace disque n’est pas illimité !!! D’où la nécessité de faire le ménage derrière soi…  Ne pas contrôler l’utilisation disque faite par ses tests, c’est l’assurance d’avoir un build continu qui échouera à un moment ou à un autre, ce n’est qu’une question de temps avant de saturer l’espace disponible ! On pourra avoir plusieurs approches:

Faire le ménage avant de partir

Merci de laisser l’endroit dans l’état dans lequel vous l’avez trouvé !

public class TestReportService {

    private static String tmpDir = System.getProperty('java.io.tmpdir');
    private static String reportPath = '/report/';
    private static File reportDir = new File(tmpDir + reportPath);

    private ReportService reportService = new ReportService();

    @BeforeClass
    public static void setupPaths() {
        // create output dir if not exists
        // ...
    }

    @Test
    public void testMyReport() {
        // ...
    }

    @AfterClass
    public static void cleanUpFiles() throws IOException {
        // remove all contained generated files and directory
        FileUtils.deleteQuietly(reportDir);
    }
}

Nota : il est préférable d’utiliser une méthode @AfterClass (ou @After) plutôt que de faire le ménage à la fin des méthodes de tests… En effet, quelle que soit l’issue d’un test, cette méthode sera invoquée par JUnit, garantissant la propreté des lieux à votre sortie

Profiter d’un endroit régulièrement nettoyé par quelqu’un d’autre

On peut par exemple écrire ses fichiers temporaires dans le target/ de Maven, automatiquement vidé lors du clean !

public class TestReportService {

    // target/ folder is cleaned by Maven when building !
    private static String reportPath = './target/report/';

    private ReportService reportService = new ReportService();

    @BeforeClass
    public static void setupPaths() {
        // create output dir if not exists
        // ...
    }

    @Test
    public void testMyReport() {
        // ...
    }
}
Utiliser les fichiers autodestructibles

Le JDK fournit une API File outillée (c’est la même que celle utilisée sur les tournages des épisodes de Mission Impossible)

public class TestReportService {

    private static String tmpDir = System.getProperty('java.io.tmpdir');
    private static String reportPath = '/report/';
    private static File reportDir = new File(tmpDir + reportPath);

    private ReportService reportService = new ReportService();

    @BeforeClass
    public static void setupPaths() throws IOException {
        // create output dir
        if (!reportDir.exists()) {
            reportDir.mkdirs();
        }
        // auto delete folder on exit
        reportDir.deleteOnExit();
    }

    @Test
    public void testMyReport() {
        String reportName = 'my_report';
        File generatedCsv = new File(tmpDir + reportPath + 'my_report.csv');
        generatedCsv.deleteOnExit(); // auto delete generated CSV on exit

        reportService.report(reportName, generatedCsv);
        File expectedCsv = readFile('src/test/resources/expected_my_report.csv');

        assertCsvEquals(expectedCsv, generatedCsv);
    }
}

Ne faites confiance à personne

Une dernière chose à propos des fichiers et des tests ; il serait dommage de passer à côté d’une régression à cause d’un excès de confiance… Imaginons par exemple que le code de la classe ReportService ait été mal écrit (qui n’a jamais été débutant dans sa vie ?) :

public class ReportService {

    public void report(String reportName, File outputFile) {
        try {
            // ...
        } catch (Exception e) {
            e.printStackTrace();
        }
        return;
    }
}

L’appel de report() pourra échouer sans pour autant faire passer en rouge son test unitaire :

public class TestReportService {

    private static String tmpDir = System.getProperty('java.io.tmpdir');
    private static String reportPath = '/report/';
    private static File reportDir = new File(tmpDir + reportPath);

    private ReportService reportService = new ReportService();

    @BeforeClass
    public static void setupPaths() throws IOException {
        // create output dir
        if (!reportDir.exists()) {
            reportDir.mkdirs();
        }
    }

    @Test
    public void testMyReport() {
        String reportName = 'my_report';
        File generatedCsv = new File(tmpDir + reportPath + 'my_report.csv');

        reportService.report(reportName, generatedCsv);
        // OOps !!! The report() invokation has thrown an exception and catched it silently before returning...

        // ... And the report file has not been dumped !!!
        File expectedCsv = readFile('src/test/resources/expected_my_report.csv');

        assertCsvEquals(expectedCsv, generatedCsv);
        // ... But the test is green because a previously generated my_report.Csv exists !
    }
}

Pour s’éviter ce genre de piège, mieux vaut être prudent et passer un petit coup de balai avant de commencer à s’installer :

public class TestReportService {

    private static String tmpDir = System.getProperty('java.io.tmpdir');
    private static String reportPath = '/report/';
    private static File reportDir = new File(tmpDir + reportPath);

    private ReportService reportService = new ReportService();

    @BeforeClass
    public static void setupPaths() throws IOException {
        // first, clean old generated files
        FileUtils.deleteQuietly(reportDir);
        // then recreate empty dir
        reportDir.mkdirs();
    }

    @AfterClass
    public static void cleanUpFiles() throws IOException {
        // clean up before leaving place
        FileUtils.forceDelete(reportDir);
    }

    @Test
    public void testMyReport() {
        //    ...
    }

}

5. Décorréler l’écriture du code de celle du test

Si vous êtes déjà familiarisés à l’écriture des tests unitaires, vous l’aurez compris : écrire une fonctionnalité et les tests associés sont 2 choses intimement liées :

  • Le test unitaire dépend évidement du code testé
  • mais la fonctionnalité doit également être écrite de manière à pouvoir être testée par une classe de test ! Qui ne s’est jamais retrouvé obligé de re-factoriser du code pour pouvoir « brancher » son test unitaire ?

Ce constat est probablement la raison d’exister des pratiques dites « Test First » ou « Test Driven Development » (écrire les tests avant/pendant la fonctionnalité). Même les détracteurs de ces pratiques s’accorderont à dire qu’il est assez exceptionnel d’écrire à posteriori un test  sur un code legacy sans avoir besoin de re-factoriser celui-ci un minimum… Alors que se passe-t-il si j’organise mon planning de travail comme suit : la première semaine, j’écris du code, la semaine suivante j’écris les tests ?

  • Je risque de commiter du code non testé (donc buggé), qui pourra être utilisé par mes camarades, et leur faire perdre un temps précieux à cause de bugs dont je suis l’auteur.
  • Je vais devoir me remémorer le contexte de chaque fonctionnalité au moment où je me décide à écrire son test. Ce travail est un coût supplémentaire que j’ai pourtant déjà payé une première fois pour développer la fonctionnalité. Je risque, de plus, d’être moins pertinent dans mes cas de tests, en oubliant par exemple les cas aux limites, qui me sont sortis de l’esprit
  • Par ailleurs,  le refactoring nécessaire pour écrire mon test sera beaucoup plus lourd, car mon code est probablement déjà utilisé un peu partout

Un dernier cas de figure sujet à polémique, dans lequel tout le monde se reconnaîtra : le cas de la « deadline » intenable. La stratégie habituellement suivie est la suivante :

  1. Je code la fonctionnalité
  2. Je teste rapidement « à la main »
  3. Je livre en UAT/Prod…
  4. Après le rush de la mise en production, je peux prendre le temps pour finalement écrire mes tests unitaires

Les objectifs seront en apparence remplis, mais il ne faudra pas perdre à l’esprit le coût du compromis :

  • Comme dit précédemment, le coût d’écriture des tests à posteriori sera plus important, et la qualité de la couverture risque d’être moins bonne
  • Il va probablement falloir re-factoriser du code déjà validé et parti en production, pour pouvoir écrire les tests. Cela impliquera de devoir valider à nouveau le code re-factorisé, pour s’assurer de la non-régression

To be continued…

Nous venons de voir, dans cette première partie, 5 cas concrets de “maladresses techniques” que l’on peut rencontrer dans le code et dans les tests,  qui rendront difficile, voire à terme impossible la maintenance de vos tests unitaires. Ces erreurs de jeunesse, bien que relativement  fréquentes, sont néanmoins des difficultés qu’il est facile de contourner ou régler ;  la pratique nous amenant à nous poser les bonnes questions, et l’expérience à maîtriser les recettes qui marchent, vos tests unitaires se porteront on ne peut mieux. Longue vie aux TU !

Lire la suite

Nombre de vue : 1216

COMMENTAIRES 9 commentaires

  1. Leriche dit :

    Pour commencer, le formalisme AAA aide beaucoup à la compréhension des TU, et à leur mécanisation.

  2. Superbe article avec de très bon exemple.

    En revanche, pour aller plus loin dans la lisibilité des assertions, rien de mieux que l’utilisation d’AssertJ, une librairie fluent très bien pensé:

    https://github.com/joel-costigliola/assertj-core

  3. Xavier Nopre dit :

    Bonjour Bruno,

    Merci pour ton article qui présente des évidences, pour certains, mais les exemples sont simples et précis.

    Tu commence en disant : “Faire des tests unitaires dans ses développements fait aujourd’hui parti des pratiques courantes”. A mon avis, d’après mes observations, la pratique est “courante” chez certains, mais de manière générale, pas du tout assez développée et répandue. Des articles comme le tien peuvent y contribuer, continuons la propagande !

    Je connaissais l’approche “DateProvider” mais l’idée de la “DateFactory” n’est pas mal 😉

    Pour les assertions “fluent”, il y a bien sûr “Fest Assert” qui est excellent, et prend en charge les List et Set 😉 …..

    Au fait, où est la suite, l’article 2/2 ?

  4. Bruno Doolaeghe dit :

    Et bien j’ignorais que le pattern “AAA” (Arrange Act Assert) avait un nom ! Je rajoute un petit lien pour ceux qui veulent en savoir plus…

    Merci également à Clément et Xavier pour leurs pointeurs vers les APIs fluent d’assert ; je reconnais être un peu “old school” de ce côté là 😉 (Ça méritait bien un petit coup de peinture “fluent” pour remettre au gout du jour !)

    @Xavier : La fin (partie 2/2) est en cours de réalisation, patience patience, ça arrive bientôt…

  5. patrick.allain dit :

    Salut !!

    J’aime beaucoup l’article :-). Très sympathique de rappeler ce genre de choses. Et entièrement d’accord avec Xavier. Le fait de réaliser des TU est loin d’être une pratique suffisamment répandue de nos jours… Et des TU bien écrit et qui servent réellement à quelque chose sont une denrée encore plus rare !!…

    Pour les API fluent, je suis 100% d’accord avec les Xavier et Clément. fest-assert est l’une des premières dépendances que j’ai rajouté sur le dernier projet ou je suis arrivé :-). En fait, quasi indispensable à avoir dans un projet pour une meilleur lisibilité des tests.

    Pour le nom AAA, pareil, je l’ignorais totalement !! Ça me semblait juste une évidence de procéder de la sorte. Merci beaucoup Leriche 😉 !!!

    Ça me fait penser à quelque chose que j’ai pu voir parfois sur certains TU ou le principe consiste à réaliser tous les tests sur une même méthode (globalement, dans l’idée du dev, les différentes branches) au sein d’une même méthode de test. Une pratique que, personnellement, je réprouve fortement… KISS, c’est l’idée de départ. Quand le test devient trop complexe, c’est souvent que la méthode (voir la classe) testée fait beaucoup trop de chose… Bref, un manque de découplage.

    Enfin, pour la partie des gros DD, j’aurais aussi surement parler de la possibilité offerte par le framework JUnit (v4 il me semble) avec la TemporaryFolder Rule

    @Xavier : Dans la pratique, c’est quoi la valeur ajoutée de assertj par rapport à fest ? Le créateur d’assertj explique les raisons de son fork avec Fest. Après, l’avantage des api fluent, c’est leur extrême simplicité d’utilisation. Dans la pratique, rajouter un maximum d’assertions (custom types par exemple), est ce que ça sert vraiment à quelque chose autrement qu’à complexifier le tout ?

    Et encore merci à tous 🙂

    Cordialement,
    Patrick Allain.

  6. […] Faire des tests unitaires dans ses développements fait aujourd’hui partie des pratiques courantes, en particulier depuis l’avènement d’eXtreme Programming et des développements agiles…  Et pourtant, qui ne s’est jamais retrouvé confronté en arrivant sur un projet legacy à la fameuse ritournelle  : « Les TU ? On les a désactivés, on n’arrivait plus à les faire marcher ! ». Alors, pas si facile les tests unitaires ? Écrire des TU est une chose, les  pérenniser en est une autre….  (Suite de la première partie)  […]

AJOUTER UN COMMENTAIRE