Tutoriel sur dix raisons pour échouer ses tests unitaires en toutes circonstances

Image non disponible

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, 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 faciles les tests unitaires ? Écrire des TU est une chose, les pérenniser en est une autre… Voici 10 anti_pratiques à ne pas suivre, tirées d'expériences réelles, qui pourraient précipiter vos tests unitaires dans les oubliettes…

Pour réagir au contenu de cet article, un espace de dialogue vous est proposé sur le forum 2 commentaires Donner une note à l'article (5).

Article lu   fois.

Les deux auteurs

Profil ProSite personnel

Liens sociaux

Viadeo Twitter Facebook Share on Google+   

I. É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
Sélectionnez
1.
2.
3.
4.
5.
6.
7.
8.
9.
10.
11.
12.
13.
14.
15.
16.
17.
18.
19.
20.
21.
22.
23.
24.
25.
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
Sélectionnez
1.
2.
3.
4.
5.
6.
7.
8.
9.
10.
11.
12.
13.
14.
15.
16.
17.
18.
19.
20.
21.
22.
23.
24.
25.
26.
27.
28.
29.
30.
31.
32.
33.
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 répandues 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 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.

  • Pensez à la lisibilité et à la clarté de votre test. Évitez 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 assertions inutiles, ou des bouts de code morts.

II. 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 ?

 
Sélectionnez
1.
2.
3.
4.
5.
6.
7.
8.
9.
10.
11.
12.
13.
14.
15.
16.
17.
18.
19.
20.
21.
22.
23.
24.
25.
26.
27.
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.

II-A. 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 :

 
Sélectionnez
1.
2.
3.
4.
5.
6.
7.
8.
9.
10.
11.
12.
13.
14.
15.
16.
17.
18.
19.
20.
21.
22.
23.
24.
25.
26.
27.
28.
29.
30.
31.
32.
33.
34.
35.
36.
37.
38.
39.
40.
41.
42.
43.
44.
45.
46.
47.
48.
49.
50.
51.
52.
53.
54.
55.
56.
57.
58.
59.
60.
61.
62.
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…

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 !

II-B. Solution 2 (avec un framework de mock)

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

 
Sélectionnez
1.
2.
3.
4.
5.
6.
7.
8.
9.
10.
11.
12.
13.
14.
15.
16.
17.
18.
19.
20.
21.
22.
23.
24.
25.
26.
27.
28.
29.
30.
31.
32.
33.
34.
35.
36.
37.
38.
39.
40.
41.
42.
43.
44.
45.
46.
47.
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);
    }
 
}

II-C. 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 3e 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…).

III. 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 :

 
Sélectionnez
1.
2.
3.
4.
5.
6.
7.
8.
9.
10.
11.
12.
13.
14.
15.
16.
17.
18.
19.
20.
21.
22.
23.
24.
25.
26.
27.
28.
29.
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.

 
Sélectionnez
1.
2.
3.
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 :

 
Sélectionnez
1.
2.
3.
4.
5.
6.
7.
8.
9.
10.
11.
12.
13.
14.
15.
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);
    }
}

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 difficile à détecter !

IV. 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…)).

 
Sélectionnez
1.
2.
3.
4.
5.
6.
7.
8.
9.
10.
11.
12.
13.
14.
15.
16.
17.
18.
19.
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).

 
Sélectionnez
1.
2.
3.
4.
5.
6.
7.
8.
9.
10.
11.
12.
13.
14.
15.
16.
17.
18.
19.
20.
21.
22.
23.
24.
25.
26.
27.
28.
29.
30.
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 deux cas, on sera amené à résoudre les problèmes habituels liés aux fichiers sur disque…

IV-A. Les problèmes classiques liés aux systèmes de fichiers

Qui dit tests unitaires, dit intégration continue et forge logicielle, et donc probablement hétérogénéité des environnements et accès au système de fichiers. 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 !

IV-A-1. Types de systèmes de fichiers

Les chemins Windows et chemins Unix étant différents, évitez au maximum tout code potentiellement dépendant de la plate-forme.

 
Sélectionnez
1.
2.
3.
4.
5.
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 !!

IV-A-2. 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.

IV-B. Système de fichiers et bonnes pratiques dans les tests

IV-B-1. Les chemins de l'enfer

Les chemins (path) des fichiers utilisés dans vos tests sont également sources d'erreur. Assurez-vous donc que les chemins de vos fichiers sont bien valides, sous 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 !) :
 
Sélectionnez
1.
File inputFile = new File('./src/test/resources/montest1.txt');
  • À défaut, chemins absolus construits depuis une racine variabilisée, par exemple :
 
Sélectionnez
1.
2.
// 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…

 
Sélectionnez
1.
2.
3.
4.
5.
6.
7.
8.
9.
10.
11.
12.
13.
14.
15.
16.
17.
18.
19.
20.
21.
22.
23.
24.
25.
26.
27.
28.
29.
30.
31.
32.
33.
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) {
        //...
    }
}

IV-B-2. 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.

IV-B-2-a. Faire le ménage avant de partir

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

 
Sélectionnez
1.
2.
3.
4.
5.
6.
7.
8.
9.
10.
11.
12.
13.
14.
15.
16.
17.
18.
19.
20.
21.
22.
23.
24.
25.
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);
    }
}

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.

IV-B-2-b. 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 !

IV-B-2-c. 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).

 
Sélectionnez
1.
2.
3.
4.
5.
6.
7.
8.
9.
10.
11.
12.
13.
14.
15.
16.
17.
18.
19.
20.
21.
22.
23.
24.
25.
26.
27.
28.
29.
30.
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);
    }
}

IV-B-2-d. 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 ?) :

 
Sélectionnez
1.
2.
3.
4.
5.
6.
7.
8.
9.
10.
11.
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.

 
Sélectionnez
1.
2.
3.
4.
5.
6.
7.
8.
9.
10.
11.
12.
13.
14.
15.
16.
17.
18.
19.
20.
21.
22.
23.
24.
25.
26.
27.
28.
29.
30.
31.
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 :

 
Sélectionnez
1.
2.
3.
4.
5.
6.
7.
8.
9.
10.
11.
12.
13.
14.
15.
16.
17.
18.
19.
20.
21.
22.
23.
24.
25.
26.
27.
28.
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() {
        //    ...
    }
 
}

V. 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 a 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 de vue le coût du compromis.

  • Comme dit précédemment, le coût d'écriture des tests a 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.

VI. Sortez couverts !

Alors voilà, ça y est, après pas mal d'efforts, vous disposez dans votre projet d'un bon paquet de tests unitaires : 95% de vos classes « intelligentes» ont leur classe de test associée ; de quoi être serein face aux évolutions à venir ! Pas convaincu ?! Et pour cause, il s'agit ici d'un indicateur assez naïf de couverture de tests.

Pour être réellement « couvert », il vous faudra bien entendu enrichir chacune de vos classes de tests de cas de tests judicieux, afin de couvrir la plus grande partie des chemins d'exécution possibles…

Prenons un exemple : voici un programme de simulation d'attaque « zombie » ; il y a d'un côté les zombies (à l'humour assez mordant), et de l'autre, une population saine, que ça ne fait pas rire du tout : chaque personne contaminée passe à son tour dans la catégorie zombie. On considère qu'en un jour, un seul zombie peut contaminer 10 personnes saines.

 
Sélectionnez
1.
2.
3.
4.
5.
6.
7.
8.
9.
10.
11.
12.
13.
14.
15.
16.
17.
18.
19.
20.
21.
22.
23.
24.
25.
26.
27.
28.
29.
30.
31.
32.
33.
public class ZombieWarSimulator {
 
   /**
   * @param initialPeople initial number of healthy people
   * @param initialZombies initial number of zombies
   * @param days defining the simulation duration
   * @return new number of zombies after the number of days
   */
   public static int getZombieNumberAfterDays(int initialPeople, int initialZombies, int days) {
      int currentPeople = initialPeople;
      int currentZombies = initialZombies;
 
      for (int day = 0; day < days; day++) {
         // each day a zombie contaminate 10 people
         int newZombies = Math.min(currentZombies * 10, currentPeople);
         currentZombies = currentZombies + newZombies;
         currentPeople = currentPeople - newZombies;
      }
 
      return currentZombies;
   }
}
 
public class ZombieWarSimulatorTest {
 
   @Test
   public void simulateZDay() {
      // 3 days after 1 initial zombie
      int newZombies = ZombieWarSimulator.getZombieNumberAfterDays(10000, 1, 3);
      assertThat(newZombies).isEqualTo(1331);
   }
 
}

Notre test est correct et valide le cas général : 3 jours après l'apparition du patient « 0 », on compte 1331 personnes infectées. Mais le code fonctionne-t-il dans les cas suivants ?

  • apparition simultanée de plusieurs patients « 0 » ;
  • pas de patient « 0 » ;
  • population entièrement contaminée ;
  • cas du jour « Z » ;
  • etc.

Pour avoir une bonne couverture de code, il nous faudra ajouter les cas aux limites à ce cas général.

 
Sélectionnez
1.
2.
3.
4.
5.
6.
7.
8.
9.
10.
11.
12.
13.
14.
15.
16.
17.
18.
19.
20.
21.
22.
23.
24.
25.
26.
27.
28.
29.
30.
31.
32.
33.
34.
35.
36.
37.
38.
public class ZombieWarSimulatorTest {
 
   @Test
   public void simulateZDay() {
      // 3 days after 1 initial  zombie
      int newZombies = ZombieWarSimulator.getZombieNumberAfterDays(10000, 1, 3);
      assertThat(newZombies).isEqualTo(1331);
   }
 
   @Test
   public void simulateZDay2Zombies() {
      // 3 days after 2 initial zombies
      int newZombies = ZombieWarSimulator.getZombieNumberAfterDays(10000, 2, 3);
      assertThat(newZombies).isEqualTo(2662);
   }
 
   @Test
   public void simulateNoZombie() {
      // no zombie
      int newZombies = ZombieWarSimulator.getZombieNumberAfterDays(10000, 0, 3);
      assertThat(newZombies).isEqualTo(0);
   }
 
   @Test
   public void simulate28DaysLater() {
      // 28 days after 1 initial zombie
      int newZombies = ZombieWarSimulator.getZombieNumberAfterDays(10000, 1, 28);
      assertThat(newZombies).isEqualTo(10001);
   }
 
   @Test
   public void simulateBeforeZDay() {
      // 1 zombie and no contamination days
      int newZombies = ZombieWarSimulator.getZombieNumberAfterDays(10000, 1, 0);
      assertThat(newZombies).isEqualTo(1);
   }
 
}

Voilà qui est beaucoup mieux ! Mieux, mais pas ultime. En effet, notre test en l'état manque d'élégance : nous avons beaucoup de duplication de code.

Heureusement, les frameworks de test fournissent aujourd'hui des solutions de tests paramétrables, telles que JUnitParams que j'utiliserai ici (ou encore le runner JUnit Parameterized, ou les Parameters de TestNG) :

 
Sélectionnez
1.
2.
3.
4.
5.
6.
7.
8.
9.
10.
11.
12.
13.
14.
15.
16.
17.
@RunWith(JUnitParamsRunner.class)
public class ZombieWarSimulatorTest {
 
   @Test
   @Parameters(value = {
      "10000, 1, 3, 1331",   // 3 days after 1 initial  zombie in 10000 people
      "10000, 2, 3, 2662",   // 3 days after 2 initial  zombies in 10000 people
      "10000, 0, 3, 0",      // no initial zombies in 10000 people
      "10000, 1, 28, 10001", // 28 days after 1 initial zombie in 10000 people
      "10000, 1, 0, 1"       // 1 zombie and no contamination days in 10000 people
   })
   public void simulateZDay(int initialPeople, int initialZombies, int days, int finalZombies) {
      int newZombies = ZombieWarSimulator.getZombieNumberAfterDays(initialPeople, initialZombies, days);
      assertThat(newZombies).isEqualTo(finalZombies);
   }
 
}

Convaincu ? C'en est tellement simple que l'on peut même « blinder » notre code, en étendant encore d'avantage notre couverture avec des cas de tests supplémentaires, pour pas plus cher !

 
Sélectionnez
1.
2.
3.
4.
5.
6.
7.
8.
9.
10.
11.
12.
13.
14.
15.
16.
17.
18.
19.
20.
21.
22.
23.
24.
25.
26.
27.
28.
29.
30.
@RunWith(JUnitParamsRunner.class)
public class ZombieWarSimulatorTest {
 
   @Test
   @Parameters(value = {
      "10000, 1, 3, 1331",         // 3 days after 1 initial  zombie in 10000 people
      "100000, 1, 4, 14641",       // 4 days after 1 initial  zombie in 100000 people
      "1000000, 1, 5, 161051",     // 5 days after 1 initial  zombie in 1000000 people
      "10000, 2, 3, 2662",         // 3 days after 2 initial  zombies in 10000 people
      "100000, 2, 4, 29282",       // 4 days after 2 initial  zombies in 100000 people
      "1000000, 2, 5, 322102",     // 5 days after 2 initial  zombies in 1000000 people
      "10000, 3, 3, 3993",         // 3 days after 3 initial  zombies in 10000 people
      "100000, 3, 4, 43923",       // 4 days after 3 initial  zombies in 100000 people
      "1000000, 3, 5, 483153",     // 5 days after 3 initial  zombies in 1000000 people
      "10000, 0, 3, 0",            // no initial zombies, 3 days later
      "10000, 0, 4, 0",            // no initial zombies, 4 days later
      "10000, 0, 5, 0",            // no initial zombies, 5 days later
      "10000, 1, 28, 10001",       // 28 days after 1 initial zombie in 10000 people
      "10000, 1, 280, 10001",      // 280 days after 1 initial zombie in 10000 people
      "10000, 1, 2800, 10001",     // 2800 days after 1 initial zombie in 10000 people
      "10000, 1, 0, 1",            // 1 zombie and no contamination days
      "10000, 2, 0, 2",            // 2 zombies and no contamination days
      "10000, 3, 0, 3"             // 3 zombies and no contamination days
   })
   public void simulateZDay(int initialPeople, int initialZombies, int days, int finalZombies) {
      int newZombies = ZombieWarSimulator.getZombieNumberAfterDays(initialPeople, initialZombies, days);
      assertThat(newZombies).isEqualTo(finalZombies);
   }
 
}

Formidable non ? Pas si sûr… En effet, les TU c'est bien, en abuser ça craint. Nous venons ici d'ajouter des cas de tests supplémentaires qui n'ont pas augmenté notre couverture de code : en effet, ils sont redondants avec les cas précédents, ils n'apportent donc rien de plus. La situation est même pire que ça.

  • Le coût récurrent de maintenance de ce test a été augmenté (si je change l'implémentation de getZombieNumberAfterDays(), je dois mettre à jour d'autant plus de valeurs de référence de « newZombies »).
  • Le test a perdu en clarté : les cas de test intéressants qu'il serait judicieux de mettre en lumière sont noyés dans un brouhaha de tests identiques.
  • Le temps d'exécution de mes tests est rallongé.

VII. Plus c'est long, plus c'est bon…

Comme nous venons de le voir, avoir une armada de tests unitaires sur son application, c'est très rassurant et sécurisant ; ça laisse supposer que l'on bénéficie d'une bonne couverture de tests qui en cas de régression s'avérera salvatrice. « C'est pas faux ! » diront certains… Mais si ça implique d'avoir des builds qui tournent pendant des plombes, cela risque fort d'être très mauvais pour la productivité et la réactivité de l'équipe de développement…

VII-A. Conséquences

Premièrement, les développeurs impatients ne prennent plus le temps de lancer tous les tests avant de commiter leur code : augmentation de la fréquence des régressions sur le repository des sources, pouvant impacter toute l'équipe, et bloquer jusqu'à l'ensemble des développements en cours.

Par ailleurs, un commit « toxique» ne sera pointé du doigt par le build continu que trop tardivement ! La correction va nécessiter un effort de remise dans le contexte de la part du développeur, qui a eu largement le temps de commencer un autre développement (qu'il va devoir mettre en pause, voire peut-être détricoter pour s'atteler à la correction du build).

Enfin, livrer un patch de production en urgence prend beaucoup plus de temps : pour construire le livrable, il faudra choisir entre :

  • faire un « skip » des tests (et livrer une version packagée potentiellement buggée) ;
  • ou bien faire un build complet avec les tests, mais par conséquent, déployer le patch avec d'autant plus de délais.

Un temps global d'exécution trop long de vos tests nuira donc à leur bonne santé : il découragera les développeurs de les entretenir, et peut aboutir à terme à leur désactivation.

VII-B. Alors que faire ?

VII-B-1. La question de la couverture

Déjà, posez-vous la question : la couverture de tests est-elle pertinente ?

  • N'y a-t-il pas des doublons dans les classes/méthodes de tests ? Peut-on en supprimer ? Ou ré-factoriser ?
  • N'y a-t-il pas des morceaux de code surtestés (faut-il vraiment tester « apache-commons » et « log4j », parce que vous les utilisez dans votre application) ?
  • N'y a-t-il pas même des tests coûteux et apportant peu de valeur ajoutée dont on peut se passer sans grand dommage ?
  • Et surtout, peut-on alléger les jeux de données d'entrée ? Ne contiennent-ils pas des redondances ? (ce qui diminuera le volume de données à traiter et donc les temps d'exécution)

VII-B-2. Revue de l'architecture

Un build trop long n'est-il pas le signe d'un mauvais découpage applicatif (projet monolithique ?) À défaut de raccourcir les temps d'exécution des tests, on doit pouvoir faire des builds partiels, afin d'éviter de lancer systématiquement tous les tests (en particulier ceux qui n'ont pas de rapport avec le code modifié).

On peut donc tenter de redécouper le projet en plusieurs artefacts, ou sous-modules Maven. On dissocie ainsi en plusieurs builds (en définissant des interfaces claires entre modules, on doit pouvoir travailler sur un seul module à la fois).

VII-B-3. Raffiner autrement les builds

Une solution alternative, qui nous évitera ce travail de découpage, consistera à ségréguer les tests par catégories pour n'en exécuter qu'une partie. On pourra ensuite utiliser les profils Maven pour définir des groupes de tests à exécuter.

VII-B-4. Optimisations

Enfin, on pourra éventuellement chercher à optimiser les tests comme on optimise une application (optimisation d'algorithmes, factorisation, parallélisme…).

VIII. Accélérer les tests en parallélisant

Comme nous venons de le voir, une des actions possibles pour lutter contre des tests unitaires trop longs, c'est de les optimiser, notamment en profitant des architectures multicœurs des machines modernes afin de les exécuter en parallèle.

VIII-A. Le geste technique

Il existe désormais des Testrunners capables d'exécuter les tests unitaires en parallèle. Je prendrai, pour illustrer mes propos, l'exemple du plugin Maven Surefire qui offre des options de parallélisation :

  • en exécutant les tests en parallèle sur un pool de threads ;
 
Sélectionnez
1.
2.
3.
4.
5.
6.
7.
8.
9.
10.
11.
12.
13.
14.
15.
<plugins>
   [...]
   <plugin>
      <groupId>org.apache.maven.plugins</groupId>
      <artifactId>maven-surefire-plugin</artifactId>
      <version>2.16</version>
      <configuration>
         <!-- make tests classes & methods runnable in parallel -->
         <parallel>both</parallel>
         <!-- use a 10 thread thread pool -->
         <threadCount>10</threadCount>
      </configuration>
   </plugin>
   [...]
</plugins>
  • ou en forkant l'exécution des tests sur plusieurs process en parallèle.
 
Sélectionnez
1.
2.
3.
4.
5.
6.
7.
8.
9.
10.
11.
12.
13.
14.
15.
<plugins>
   [...]
   <plugin>
      <groupId>org.apache.maven.plugins</groupId>
      <artifactId>maven-surefire-plugin</artifactId>
      <version>2.16</version>
      <configuration>
         <!-- run tests on a pool of 3 concurrent process -->
         <forkCount>3</forkCount>
         <!-- use a pool of JVMs -->
         <reuseForks>true</reuseForks>
      </configuration>
   </plugin>
   [...]
</plugins>

VIII-B. Le 2e effet Kiss Cool

Ces optimisations (par multithreading ou forking) sont en apparence séduisantes, car non intrusives par rapport au code (les seules modifications se situent dans la configuration de Surefire dans le pom.xml). Dans la pratique, les choses ne sont pas si simples. Eh oui, qui dit parallélisme dit problèmes de race conditions, deadlocks, et autres joyeusetés concurrentes ! Voici pêle-mêle, quelques exemples de problèmes que vous serez susceptibles de rencontrer…

VIII-B-1. Quand les tests couvrent du code non thread-safe

Le problème n°1, comment peut-on tester du code non thread-safe dans un contexte d'exécution de tests que l'on souhaite multithreadé ? Prenons l'exemple suivant d'un cache (certes primitif et naïf je vous l'accorde mais néanmoins parfait pour cet exemple) :

 
Sélectionnez
1.
2.
3.
4.
5.
6.
7.
8.
9.
10.
11.
12.
13.
14.
15.
16.
17.
18.
19.
20.
21.
22.
23.
24.
25.
26.
27.
28.
29.
30.
31.
32.
33.
34.
35.
36.
37.
38.
39.
40.
41.
42.
43.
44.
45.
46.
47.
48.
49.
50.
51.
52.
53.
54.
55.
public interface Cache {
   Object get(int id);
   int size();
   void flush();
}
 
//not thread safe impl
public class CacheImpl implements Cache {
 
   Map<Integer, Object> cache = new HashMap<Integer, Object>();
   int size = 0;
 
   public Object get(int id) {
      if (cache.get(id) == null) {
         cache.put(id, load(id));
         size++;
      }
      return cache.get(id);
   }
 
   protected Object load(int id) {
      // load from DB...
   }
 
   void flush() {
      cache = new HashMap<Integer, Object>();
      size = 0;
   }
 
   public int size() {
      return size;
   }
 
}
 
public class CacheTest {
 
   private static Cache cache = new CacheImpl();
 
   @Test
   public void testLoadFromCache() throws Exception {
      cache.flush();
      cache.get(1);
      assertEquals("1 object should be in cache", 1, cache.size());
   }
 
   @Test
   public void testLoad2FromCache() throws Exception {
      cache.flush();
      cache.get(1);
      cache.get(2);
      assertEquals("2 objects should be in cache", 2, cache.size();
   }
 
}

Les tests fonctionnent correctement en mode séquentiel. Néanmoins, lorsque l'on active le mode « parallel » de Surefire, nos tests échouent :

 
Sélectionnez
1.
2.
3.
Failed tests:
CacheTest.testLoad2FromCache:25 2 objects should be in cache expected:<2> but was:<3>
CacheTest.testLoadFromCache:18 1 object should be in cache expected:<1> but was:<3>

On ne peut cependant pas en vouloir au code de CacheImpl.java (cette implémentation n'est pas supposée thread-safe). C'est ici le test runner qui ne respecte plus le contrat d'utilisation de notre cache.

VIII-B-1-a. Option n°1 : Rendre le code thread-safe ?

Pourquoi ne pas modifier CacheImpl pour le rendre thread-safe ? Ce sera fait sans grande difficulté. Mais est-ce malgré tout bien raisonnable ? Changer du code métier, juste pour accélérer les tests, avec toutes les conséquences que cela peut entraîner sur la « prod » (risque de deadlocks, impact sur les performances) ?

VIII-B-1-b. Option n°2 : Rendre le test thread-safe ?

Et sinon, ne peut-on pas tout simplement rendre le test CacheTest lui-même thread-safe ?

 
Sélectionnez
1.
2.
3.
4.
5.
6.
7.
8.
9.
10.
11.
12.
13.
14.
15.
16.
17.
18.
19.
20.
21.
22.
23.
24.
25.
26.
27.
28.
public class CacheTest {
 
   private static Cache cache = new CacheImpl();
 
   @Test
   public void testLoadFromCache() throws Exception {
      int size = 0;
      synchronized (cache) {
         cache.flush();
         cache.get(1);
         size = cache.size();
      }
      assertEquals("1 object should be in cache", 1, size);
   }
 
   @Test
   public void testLoad2FromCache() throws Exception {
      int size = 0;
      synchronized (cache) {
         cache.flush();
         cache.get(1);
         cache.get(2);
         size = cache.size();
      }
      assertEquals("2 objects should be in cache", 2, cache.size());
   }
 
}

« Super ! » me direz-vous, on a corrigé notre problème de concurrence, sans modifier le code du cache (le test repasse au vert) !

Mais encore une fois, est-ce bien raisonnable ? Nous venons de « ré-séquentialiser» les tests qu'on essayait de « paralléliser » ; on a rendu le code du test moins maintenable, pour un gain de performance nul : retour à la case départ.

VIII-B-1-c. Option n°3 : forker les JVM pour passer les tests ?

Enfin, on peut paralléliser autrement que par multithreading : en forkant l'exécution des tests sur plusieurs JVM, et ainsi éviter l'accès concurrent sur la même instance du cache. C'est probablement ici la solution à privilégier.

VIII-B-2. Quand les tests eux-mêmes sont non thread-safe

Ce cas de figure est, pour ainsi dire, le « cas général ». En effet, le code des tests unitaires a en général été écrit (et c'est bien légitime) sans préoccupation concurrentielle… Il sera donc souvent exposé à des problèmes liés à l'exécution en parallèle.

VIII-B-2-a. État partagé

Pour illustrer ce propos, prenons une implémentation thread-safe de notre cache (celle-ci est peut-être naïve et peu performante, mais suffisante pour notre exemple ;-)) :

 
Sélectionnez
1.
2.
3.
4.
5.
6.
7.
8.
9.
10.
11.
12.
13.
14.
15.
16.
17.
18.
19.
20.
21.
22.
23.
24.
25.
26.
27.
28.
29.
30.
31.
32.
33.
34.
public interface Cache {
   Object get(int id);
   void flush();
   int size();
}
 
// thread-safe cache impl
public class CacheImpl implements Cache {
 
   Map<Integer, Object> cache = new HashMap<Integer, Object>();
   int size = 0;
 
   public synchronized Object get(int id) {
      if (cache.get(id) == null) {
         cache.put(id, load(id));
         size++;
      }
      return cache.get(id);
   }
 
   protected Object load(int id) {
      // load from DB...
   }
 
   public synchronized void flush() {
      cache = new HashMap<Integer, Object>();
      size = 0;
   }
 
   public synchronized int size() {
      return size;
   }
 
 }

Voici un test unitaire legacy associé, qui compte les appels à la base :

 
Sélectionnez
1.
2.
3.
4.
5.
6.
7.
8.
9.
10.
11.
12.
13.
14.
15.
16.
17.
18.
19.
20.
21.
22.
23.
24.
25.
26.
27.
28.
29.
30.
31.
32.
33.
34.
35.
public class CacheSpyImpl extends CacheImpl implements Cache {
   public static int loadNumber = 0;
   @Override
   protected Object load(int id) {
      loadNumber++;
      return super.load(id);
   }
}
 
public class CacheTest {
 
   private Cache cache = new CacheSpyImpl();
 
   @Before
   public void initCache() {
      CacheSpyImpl.loadNumber = 0;
      cache.flush();
   }
 
   @Test
   public void testLoadFromCache() throws Exception {
      cache.get(1);
      assertEquals("The object should have been loaded from DB", 1,      CacheSpyImpl.loadNumber);
   }
 
   @Test
   public void testReloadFromCache() throws Exception {
      cache.get(1);
      assertEquals("The cache should have been loaded from DB", 1, C     acheSpyImpl.loadNumber);
      // get from cache
      cache.get(1);
      assertEquals("The object should be in cache", 1, CacheSpyImpl.loadNumber);
   }
 
}

Ce test, qui fonctionnait parfaitement en séquentiel, passe à présent en rouge lorsque l'on active le mode parallel de Surefire :

 
Sélectionnez
1.
2.
3.
Failed tests:
CacheTest.testReloadFromCache:29 The cache should have been loaded from DB expected:<1> but was:<2>
CacheTest.testLoadFromCache:23 The object should have been loaded from DB expected:<1> but was:<2>

On comprend aisément la difficulté qui apparaît ici (malgré une implémentation de cache thread-safe) : quand les méthodes du test (lui, non thread-safe !) s'exécutent en parallèle, le compteur d'appels loadNumber static de la classe CacheSpyImpl est incrémenté en « même temps » par les 2 threads de tests ; sa valeur devient donc totalement « incohérente » (sans parler des possibles problèmes d'inconsistance mémoire). Le code de test s'appuyant sur un état static (ce serait pareil avec un singleton) est donc à bannir dans un environnement multithreadé.

Il est intéressant de noter une alternative salvatrice dans notre cas : le mode fork de Surefire devrait nous éviter ce genre de « collision » : on aura une classe CacheSpyImpl chargée par JVM, et donc plus de partage d'état. Attention cependant à ne pas « pooler » les JVM ! (reuseFork=true)

VIII-B-2-b. Race conditions : le retour de la vengeance

Nous venons de voir que partager un registre mémoire commun peut aboutir à des race conditions aboutissant à l'échec de nos tests. Plus globalement, ce genre de situation peut dériver de n'importe quel partage de ressource. Prenons cet autre exemple :

 
Sélectionnez
1.
2.
3.
4.
5.
6.
7.
8.
9.
10.
11.
12.
13.
14.
15.
16.
17.
18.
19.
20.
21.
22.
23.
24.
25.
26.
public class FnocServiceTest {
 
   String outputReport = "output.csv";
   FnocService service = new FnocService();
 
   @Test
   public void testCreateOrdersReport() throws Exception {
      // create orders report
      dumpReeport(new Order("Best of Star Acadebide"), outputReport);
      // check result from file system
      String actualReport = readFile(outputReport);
      String expectedReport = readFile("src/test/resources/order_report_cd.csv");
      assertEquals(expectedReport, actualReport);
   }
 
   @Test
   public void testCreateOrdersReport() throws Exception {
      // create orders report
      service.dumpReeport(new Order("Walking Beer", "World Bar Z"), outputReport);
      // check result from file system
      String actualReport = readFile(outputReport);
      String expectedReport = readFile("src/test/resources/order_report_bluray.csv");
      assertEquals(expectedReport, actualReport);
   }
 
}

Ce test qui fonctionne parfaitement en exécution séquentielle, vire au rouge dès que l'on active le mode parallel de Surefire ; pire même : le mode fork cette fois-ci ne nous sauve pas la mise ! En effet, la ressource partagée est ici le fichier output.csv, accédé en écriture de manière concurrente par les deux tests (pas de « collision mémoire », exit la solution du fork).

Et pourtant, la situation n'est pas si dramatique : il suffit en effet d'éviter les « collisions » de noms de fichiers pour que tout rentre dans l'ordre :

 
Sélectionnez
1.
2.
3.
4.
5.
6.
7.
8.
9.
10.
11.
12.
13.
14.
15.
16.
17.
18.
19.
20.
21.
22.
23.
24.
25.
26.
27.
28.
29.
public class FnocServiceTest {
 
   FnocService service = new FnocService();
 
   @Test
   public void testOrdersReportCD() throws Exception {
      // generate unique file name e.g /tmp/out~7549426430997112982.csv
      String outFile = File.createTempFile("out~", ".csv").getAbsolutePath();
      // create orders report
      service.dumpReeport(new Order("Best of Star Acadebide"), outFile);
      // check result from file system
      String actualReport = readFile(outFile);
      String expectedReport = readFile("src/test/resources/order_report_cd.csv");
      assertEquals(expectedReport, actualReport);
   }
 
   @Test
   public void testOrdersReportBluray() throws Exception {
      //generate unique file name e.g /tmp/out~54659864654654.csv
      String outFile = File.createTempFile("out~", ".csv").getAbsolutePath();
      // create orders report
      service.dumpReeport(new Order("Walking Beer", "World Bar Z"), outFile);
      // check result from file system
      String actualReport = readFile(outFile);
      String expectedReport = readFile("src/test/resources/order_report_bluray.csv");
      assertEquals(expectedReport, actualReport);
   }
 
}

On rencontrera la même problématique avec d'autres types de ressources partagées :

  • les répertoires (que se passe-t-il si on crée/supprime dans une méthode @Before/@After un répertoire de dump utilisé dans testOrdersReportCD() et testOrdersReportBluray() ?) ;
  • les sockets et les numéros de port ;
  • ou encore, avec des données en base.

VIII-B-3. Pour conclure sur la parallélisation

Comme nous venons de voir à travers ces quelques exemples, multithreader les tests crée bien souvent de nouvelles « dépendances » entre les cas de tests, qui n'existaient pas en exécution séquentielle :

  • partage d'une zone mémoire commune (état d'une classe, d'un singleton) ;
  • partage d'une ressource extérieure commune (fichier, répertoire, socket).

Ces « collisions » engendrées par la parallélisation nécessitent de mettre en place quelques adaptations. Plusieurs options s'offrent à nous :

  • corriger le test, mais cela n'est pas toujours possible, et revient parfois à re-séquentialliser les tests parallélisés ;
  • corriger le code, mais rendre du code thread-safe (pour pouvoir paralléliser les tests), c'est le complexifier. Attention donc à la maintenance, qui en sera ensuite plus coûteuse. Attention également aux incidents de production, en cas de race conditions !
  • forker les JVM. Certains problèmes d'accès concurrents ne seront toutefois pas résolus par cette solution. Par ailleurs, cela peut engendrer d'autres problèmes (utilisation mémoire).

Quelle que soit la stratégie choisie, ces adaptations ne sont ni anodines, ni gratuites :

  • rendre thread-safe le code ou les tests nécessite du temps de mise en place ;
  • ce code sera plus complexe, donc plus coûteux à maintenir ;
  • les tests rendus parallèles seront moins robustes (il est toujours difficile de comprendre, reproduire, et diagnostiquer un problème d'accès concurrent) ;
  • les futurs tests seront également un peu plus coûteux à écrire, car ils devront eux aussi être thread-safe.

En conclusion, paralléliser les tests raccourcira bien le temps de build, mais le prix à payer sera un surcoût sur l'écriture et la maintenance des tests ! Alors, êtes-vous prêt à payer ce prix ?

VIII-B-4. Derniers conseils pour la route

Même si on réussira globalement à paralléliser les tests sur un projet, il sera intéressant, voire impératif, de se garder un mode de fonctionnement dégradé (exécution classique non parallèle des tests) :

  • afin de diagnostiquer les tests en échec à cause de la parallélisation ;
  • pour ne pas rester bloqué sur un build, dans les périodes critiques (build de release, patch en urgence) ;
  • enfin, il restera bien souvent quelques cas de tests particuliers qui seront trop coûteux ou difficiles à rendre thread-safe. Il serait dommage de devoir renoncer à la parallélisation de l'ensemble des tests, à cause d'un ou deux cas pathologiques isolés !

VIII-B-4-a. Implémentation

On pourra utiliser les Categories JUnit pour ségréguer nos tests unitaires thread-safe et non thread-safe. Il suffit pour cela de déclarer une category spéciale pour les tests non thread-safe :

 
Sélectionnez
1.
2.
3.
4.
/**
 * A JUnit4 Category for test that need to be executed in a monothread environnemnet (disable parallelisation of tests execution)
*/
public interface SequentialTestCategory { }

On décore ensuite les tests non thread-safe pathologiques :

 
Sélectionnez
1.
2.
3.
4.
5.
6.
7.
8.
9.
10.
11.
12.
@Category(SequentialTestCategory.class)
public class TestComplicatedAndNotThreadSafeCode {
 
   public void testUnthreadsafeMethod1() {
      ...
   }
 
   public void testUnthreadsafeMethod2() {
      ...
   }
 
}

Enfin, on déclare deux configurations de Surefire dans le pom.xml du projet :

 
Sélectionnez
1.
2.
3.
4.
5.
6.
7.
8.
9.
10.
11.
12.
13.
14.
15.
16.
17.
18.
19.
20.
21.
22.
23.
24.
25.
26.
27.
28.
29.
30.
31.
32.
33.
34.
35.
36.
37.
38.
39.
<properties>
   <test.parallel>both</test.parallel>
</properties>
...
 
<plugin>
   <groupId>org.apache.maven.plugins</groupId>
   <artifactId>maven-surefire-plugin</artifactId>
 
   <executions>
      <execution>
         <!-- thread safe tests -->
         <id>default-test</id>
         <phase>test</phase>
         <goals>
            <goal>test</goal>
         </goals>
         <configuration>
            <excludedGroups>SequentialTestCategory</excludedGroups>
            <parallel>${test.parallel}</parallel>
            <perCoreThreadCount>true</perCoreThreadCount>
            <threadCount>10</threadCount>
         </configuration>
      </execution>
      <execution>
         <!-- thread unsafe tests -->
         <id>sequential-tests</id>
         <phase>test</phase>
         <goals>
            <goal>test</goal>
         </goals>
         <configuration>
            <groups>SequentialTestCategory</groups>
            <parallel>none</parallel>
         </configuration>
      </execution>
   </executions>
</plugin>
...

Les tests non thread-safe seront alors exécutés à part sur un seul thread, évitant les problèmes de concurrence pour ceux-ci !

Enfin, en cas de besoin, on pourra globalement débrayer le mode multithreadé pour tous les tests en surchargeant la variable « test.parallel » :

 
Sélectionnez
1.
/$ mvn test -Dtest.parallel=none

VIII-B-5. Le coût de la simplicité

Une dernière solution d'optimisation s'offre à nous aujourd'hui, en cadeau dans chaque boite de Maven 3 ; eh oui, cette dernière version propose nativement des builds en parallèle pour un projet modulaire : chaque module indépendant peut en effet être buildé en même temps, puisqu'ils ne dépendent pas les uns des autres. Cette solution offre par ailleurs les avantages suivants :

  • mise en œuvre gratuite, car native, on profite juste du découpage modulaire ;
  • beaucoup moins de risques de race conditions, puisque on teste en parallèle des modules indépendants et non des classes/méthodes ;
  • une migration de Maven 2 à Maven 3 est peu coûteuse (rien à voir avec une migration Maven 1 vers Maven 2 !).

Cette solution est probablement celle qui offre le meilleur « ROI », même si le gain à la clé est moindre que la parallélisation par Surefire, son coût de mise en place et de maintenance est très intéressant !

IX. La grenouille qui se prenait pour un bœuf

À présent, voici l'histoire de Bob. Qui est Bob ? Un développeur lambda plus vraiment junior, pas vraiment senior, Bob c'est vous, c'est moi. À travers ses expériences passées, Bob a fini par comprendre (certes dans la douleur), pourquoi tester son code. Il est aujourd'hui parfaitement convaincu de l'utilité des tests, la difficulté résidant à présent dans « l'art et la manière »

Bob travaille actuellement sur le projet« Pwetter », un système de microblogage très prometteur. Il vient de développer une nouvelle fonctionnalité, permettant de rechercher les « Pwets » postés contenant certains mots-clés. Bien décidé à mettre en pratique les bons principes, il ajoute dans le projet la classe de test unitaire suivante :

 
Sélectionnez
1.
2.
3.
4.
5.
6.
7.
8.
9.
10.
11.
12.
13.
14.
15.
16.
17.
18.
19.
20.
21.
22.
23.
24.
25.
26.
27.
28.
public class PwetterSearchTest {
 
   @BeforeClass
   public void static startup() {
      PwetServer.connect("mongodb://dbdev1.pwetter.net,dbdev2.pwetter.net:2500/?replicaSet=test");
   }
 
   @AfterClass
   public void static shutdown() {
      PwetServer.disconnect();
   }
 
   @Test
   public void testSearch() throws Exception {
      // Arrange
      PwetService api = PwetServer.getServiceInterface();
      api.post("Hey people ! So@t rocks !!!");
      api.post("Be smart, enjoy coding");
      api.post("So@t, what else ?");
 
      // Act
      List<Pwet> pwets = api.search(new KeywordFilter("So@t"));
 
      // Assert
      assertThat(pwets).containsExactly("Hey people ! So@t rocks !!", "So@t, what else ?");
   }
 
}

Bob exécute son test, tout fonctionne. Et pourtant, ce n'était peut-être pas une bonne idée. Mais il est où le problème, me direz-vous ?

Même s'il faut accorder à Bob que :

  • le test unitaire fonctionne ;
  • il couvre une fonctionnalité importante ;
  • il valide l'algorithme de recherche, la persistance, et l'intégration réussie dans le reste du projet.

… Il n'a d'unitaire que le nom, et ressemble furieusement à un « test d'intégration » :

  • il teste une fonctionnalité entière, qui s'appuie sur plusieurs « briques logicielles », qui couvre une grande partie de code, et dans un contexte « intégré » avec le reste du code ;
  • il dépend de la disponibilité de la « base » ;
  • il peut être assez long à s'exécuter.

En outre, un test d'intégration ne remplace pas les tests unitaires du code sous-jacent, mais il en est complémentaire. Un test unitaire trop gourmand (qui couvre beaucoup de code dans un seul test) :

  • sera fragile (la moindre évolution du code le fera virer au rouge) ;
  • sera très difficile à maintenir (il ne sera pas aisé de trouver l'origine du problème dans un périmètre aussi vaste !) ;
  • nous donnera finalement une faible couverture de code (dans un use case fonctionnel, on ne teste pas les n cas aux limites des briques logicielles sous-jacentes).

Ce test a malgré tout une certaine valeur, mais il n'a pas sa place parmi les tests unitaires. Il doit en être isolé (avec les autres « tests d'intégration »), afin de pouvoir être exécuté de manière dissociée des vrais tests unitaires. Ainsi, les vrais tests unitaires ne seront pas pénalisés par sa fragilité !

X. Faux-positifs

Qui dans sa vie de développeur n'a jamais été confronté au fameux test, qui des fois passe, des fois échoue ? Nous venons de le voir, les raisons possibles sont multiples :

  • bugs d'accès concurrents ;
  • logique métier testée intrinsèquement non déterministe ;
  • problèmes aléatoires de dépassement de mémoire (OutOfMemory) ;
  • indisponibilité temporaire du réseau ;
  • indisponibilité temporaire de la base ;
  • espace disque insuffisant ;
  • partage d'une ressource commune non stable (telle qu'un composant tiers extérieur) ;
  • utilisation d'une dépendance SNAPSHOT.

Quelle qu'en soit la cause, ces tests en échec sur le build continu, qui mystérieusement se réparent tout seuls viennent briser votre concentration, et parasiter votre tâche en cours, et émoussent irrémédiablement votre vigilance : c'est l'histoire du jeune garçon qui criait « au loup », ces faux positifs vous amènent à ne plus prêter attention aux builds en échec sur la forge logicielle… Ce « bruit » implique des retards sur la correction des véritables régressions, noyées dans la masse, ce qui aura pour conséquences :

  • l'augmentation du coût de maintenance des tests (plus une correction est faite tardivement, plus elle est difficile, plus elle coûte cher) ;
  • la généralisation du « bypass » des tests de la part les développeurs.

Cela peut aboutir à la désactivation partielle voire globale des tests unitaires par un développeur exaspéré (voilà, les TU sont morts, « game over ») !

Ces faux positifs sont une plaie, parfois trop souvent pris à la légère, qu'il faut à tout prix régler au plus tôt :

  • en corrigeant le problème, si c'est corrigeable ;
  • à défaut, en isolant en quarantaine les tests pathologiques (en les catégorisant par exemple), et ainsi éviter les faux positifs à répétition sur le continuous build (en attendant une réelle correction, les tests pathologiques seront lancés avec une fréquence moindre, et uniquement dans un contexte dédié, sous contrôle d'un développeur averti) ;
  • dans le pire des cas et faute de mieux, en désactivant/supprimant le test incriminé, qui finalement fait plus de mal que de bien (la question à se poser à ce moment-là est bel est bien : « le soldat Ryan mérite-t-il d'être sauvé ? »).

XI. Le mot de la fin

Nous avons passé en revue des problématiques que l'on rencontre classiquement quand on écrit et maintient des tests unitaires automatisés… Même si certains problèmes ont des solutions, et ne demandent qu'un peu d'expérience ou d'astuce pour être résolus, d'autres en revanche n'ont pas de solution “miracle”, et appellent au compromis, et nous amèneront à nous poser la question : quel est le coût de mise en place, le coût de maintenance, et le gain à la clé ?

XI-A. Actif

Quel est l'apport des tests unitaires ?

  • la qualité de l'application : une production qui va mieux (moins de bugs, et moins de régressions, donc moins de support et de bug fixing !) ;
  • la qualité du code (une diminution de la dette technique et du coût de maintenance) :

    • un meilleur design de code induit par sa testabilité, un contrat d'utilisation plus clair des composants, une granularité plus juste (plus fine ou plus grossière), une meilleure gestion des dépendances entre composants de l'application,
    • avoir des tests unitaires en filet de sécurité, c'est la possibilité de faire sereinement du refactoring de code qui nous permet plus d'agilité ;
  • une documentation (un exemple d'utilisation), qui améliore la productivité des développeurs.

XI-B. Passif

Quel est le coût des tests unitaires ?

  • le coût supplémentaire d'écriture des tests ;
  • le coût de maintenance des tests existants :

    • analyser les tests en échec (problèmes des faux positifs, problèmes des « qui s'en occupe »),
    • correction des tests suite aux évolutions,
    • maintenance de l'environnement nécessaire aux tests (forge logicielle, base, espace disque, ressources extérieures) ;
  • un time-to-market qui peut paraître plus long : en effet, peut-on se permettre de livrer en urgence un patch de production lorsque ses tests sont rouges ? Et doit-on attendre (parfois plusieurs heures) la fin de l'exécution de tous les tests avant de pouvoir le livrer ?
  • les risques de dérive : trop de tests tuent les tests :

    • lorsque les coûts de maintenance code vs. tests sont déséquilibrés, que le plus gros de l'effort correctif se situe dans les tests et non dans le code, c'est symptomatique d'une perversion des bons principes de tests (couverture redondante, inadaptée, majoritairement non pertinente ?),
    • lorsque le code devient compliqué à cause des tests (par exemple, rendre une classe thread-safe uniquement pour les tests).

XI-C. To test or not to test ?

Pas de réponse absolue, chaque contexte a ses particularités, chaque solution ses avantages et ses inconvénients… Peser le pour et le contre, ne pas négliger les contraintes supplémentaires qu'une réponse peut apporter, arbitrer en toute connaissance de cause, ne pas ignorer les conséquences de nos décisions sur le moyen et long terme : c'est là que se situe notre valeur ajoutée !

XII. Remerciements

Cet article a été publié avec l'aimable autorisation de la société SoatSoat. Les articles originaux (10 trucs infaillibles pour rater ses tests unitaires en toutes circonstances partie 1 et partie 2) peuvent être vus sur le blog/site de Soat.

Nous tenons à remercier ced pour sa relecture attentive de cet article et Mickaël Baron pour la mise au gabarit.

Vous avez aimé ce tutoriel ? Alors partagez-le en cliquant sur les boutons suivants : Viadeo Twitter Facebook Share on Google+   

  

Copyright © 2014 SOAT. Aucune reproduction, même partielle, ne peut être faite de ce site et de l'ensemble de son contenu : textes, documents, images, etc. sans l'autorisation expresse de l'auteur. Sinon vous encourez selon la loi jusqu'à trois ans de prison et jusqu'à 300 000 € de dommages et intérêts.