10 trucs infaillibles pour rater ses tests unitaires en toutes circonstances (2/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, 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

Nous allons voir dans cette seconde partie d’autres problématiques, toujours tirées d’expériences réelles, mais qui cette fois-ci, n’auront pas forcément de réponse universelle et consensuelle. Je présenterai les problèmes, et proposerai des solutions (avec leurs avantages et leurs inconvénients)…  Qui donneront très certainement matière à débat !

6. 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 test…

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.

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 :

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) :

@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 !

@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 des 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é

7. 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 test 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 réactivité de l’équipe de développement…

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 même 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 à les entretenir, et peut aboutir à terme à leur désactivation.

Alors Que faire ?

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 sur-testé (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)

Revue de l’architecture

Un build trop long n’est-il pas le signe d’un mauvais découpage applicatif (projet monolithique ?) A 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).

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.

Optimisations

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

8. 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 multi-cœurs des machines modernes afin de les exécuter en parallèle.

Le geste technique

Il existe désormais des Testrunners capables d’exécuter les tests unitaires en parallèle. Je prendrais, 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
<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
<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>

Le 2ème effet Kiss cool

Ces optimisations (par multi-threading 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… Et 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…

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 multi-threadé ?  Prenons l’exemple suivant d’un cache (certes primitif et naïf je vous l’accorde mais néanmoins parfait pour cet exemple ;-)) :

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…

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.

Alors que faire ?

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

Option n°2 : Rende le test thread-safe ??

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

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…

Option n°3 : forker les JVM pour passer les tests ???

Enfin, on peut paralléliser autrement que par multi-threading : 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.

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

Etat 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 ;-)) :

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  :

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 :

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’appel 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 multi-threadé…

NB : 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)

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 :

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 2 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 :

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);
   }

}

N.B. : 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

Pour conclure sur la parallélisation

Comme nous venons de voir à travers ces quelques exemples,  multi-threader 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 resource 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 ?

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 !

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 :

/**
 * 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 :

@Category(SequentialTestCategory.class)
public class TestComplicatedAndNotThreadSafeCode {

   public void testUnthreadsafeMethod1() {
      ...
   }

   public void testUnthreadsafeMethod2() {
      ...
   }

}

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

<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 multi-threadé pour tous les tests en surchargeant la variable “test.parallel” :

/$ mvn test -Dtest.parallel=none

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 ; et 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 oeuvre gratuite, car native, on profite juste du découpage modulaire
  • beaucoup moins de risque de race conditions, puisque on teste en parallèle des modules indépendants et non des classes/méthodes
  • une migration de Maven 2 à Maven3  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 !

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

A préfrogeefsent, 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. A 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 :

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

tim_dans_ton_tuMê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 au final 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 pourvoir ê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é !

10. 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 adresser 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 au final 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é ?)

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é ?

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

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

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 !

Nombre de vue : 776

COMMENTAIRES 6 commentaires

  1. Thibaud dit :

    Génial! Y a vraiment du boulot derrière, merci pour ce partage.

    Je ne suis pas tout à fait d’accord avec la solution dans le point 6 (“sortez couverts”). La duplication de code dans les tests a ici un avantages par rapport à la solution avec 1 méthode + paramètres : Si un des cas échoue on identifie beaucoup plus vite lequel. L’intégration continue va signaler que le test ZombieWarSimulatorTest#simulateNoZombie() est rouge => on sait tout de suite le cas qui pose pb sans même lire la trace générée par le framework qui fait les assertions.

    Dans le cas de tests paramétrés, on sait que ZombieWarSimulatorTest#simulateZDay() est rouge, mais sur quels paramètres?

    Je pense qu’il faut parfois savoir ne pas tomber dans le refactoring excessif. Mais bon c’était annoncé au départ: “pas de réponses universelles et consensuelles” 🙂

    Thibaud

  2. Fabian dit :

    Merci pour cette suite d’articles sur les TU. Je ne connaissais pas la parallélisation des tests…

    Mention spéciale pour l’humour! Les pwets de vache ont peut être moins de classe que les tweets d’oiseau, mais c’est marrant 😉

  3. Bruno Doolaeghe dit :

    @Thibaud :

    “La duplication de code dans les tests a ici un avantages par rapport à la solution avec 1 méthode + paramètres : Si un des cas échoue on identifie beaucoup plus vite lequel. L’intégration continue va signaler que le test ZombieWarSimulatorTest#simulateNoZombie() est rouge => on sait tout de suite le cas qui pose pb sans même lire la trace générée par le framework qui fait les assertions.”

    D’accord avec toi sur ce point ! on perd en clarté sur les rapports de tests avec les Parameters

    J’ai en complément une dernière alternative à te proposer, avec les Parameters JUnit :

    @RunWith(value = Parameterized.class)
    public class ZombieWarSimulatorTest {

    @Parameter(0)
    public int initialPeople;
    @Parameter(1)
    public int initialZombies;
    @Parameter(2)
    public int days;
    @Parameter(3)
    public int expectedFinalZombies;
    @Parameter(4)
    public String message;

    @Parameters(name="{4} : simulating {1} initial zombies in {0} people after {2} days")
    public static Collection<Object[]> data() {
    Object[][] data = new Object[][] {
    { 10000 , 1, 3, 1331, "usual case"},
    { 10000 , 2, 3, 2662, "usual case with more zombies"},
    { 10000 , 0, 3, 0, "special case with no zombies"},
    { 10000 , 1, 28, 10001, "limit case after a long time"},
    { 10000 , 1, 0, 1, "case before contamination"},
    };
    return Arrays.asList(data);
    }

    @Test
    public void simulateZDay() {
    int newZombies = ZombieWarSimulator.getZombieNumberAfterDays(initialPeople, initialZombies, days);
    assertThat(newZombies).isEqualTo(expectedFinalZombies);
    }

    }

    … Qui te permettent d’améliorer un peu la lisibilité de tes rapports de tests :

    ZombieWarSimulatorTest
    – simulateZDay[usual case : simulating 1 initial zombies in 10000 people after 3 days]
    – simulateZDay[usual case with more zombies : simulating 2 initial zombies in 10000 people after 3 days]
    – simulateZDay[special case with no zombies : simulating 0 initial zombies in 10000 people after 3 days]
    – simulateZDay[limit case after a long time : simulating 1 initial zombies in 10000 people after 28 days]
    – simulateZDay[case before contamination : simulating 1 initial zombies in 10000 people after 0 days]

    Je n’ai pas trouvé d’équivalent avec JunitParams. Peut-être dans une prochaine version ?

  4. […] rester dans la ligne de l’excellent post de Bruno Doolaeghe, je vous propose un tutoriel sur JUnit. Même si TestNG est de plus en plus populaire, on n’a […]

AJOUTER UN COMMENTAIRE