<
Media
>
Article

Tester votre code autrement avec ApprovalTests

7 min
11
/
07
/
2022

Il y a quelques temps, j'ai regardé une vidéo hypnotique d'Emily Bache, dans laquelle elle travaille sur le kata) dit "Gilded Rose". Le but de ce kata est de rajouter une petite feature a un code qui est illisible de prime abord. Comme il n'y a pas de test, elle commence d'abord par en écrire. Elle peut ainsi se lancer dans un refactoring massif du code, pour le simplifier drastiquement, en s'assurant qu'elle ne casse rien. Au final, elle rajoute facilement la nouvelle feature.

J'ai évidemment admiré son utilisation d'IntelliJ et et sa méthodologie, mais ce qui a vraiment retenu mon attention dans cette vidéo, c'est le framework qu'elle utilise : ApprovalTests. Le concept est très différent des tests unitaires que j'ai l'habitude de faire, et j'ai immédiatement eu envie de l'essayer sur mon projet en C++ (sur lequel les tests unitaires sont faits avec Catch2). Et après j'ai eu envie de vous en parler !

Dans cet article, j'utilise l'implémentation pour Java, en binôme avec JUnit, histoire de faire croire que je sais faire autre chose que du Python et du C++ !

Si vous ne goutez ni à Java ni à C++, sachez qu'ApprovalTests est aussi disponible en C#, PHP, Python, Swift, NodeJS, Perl, Go, Lua, Objective-C ou encore Ruby.

Mise en place du projet avec Maven

Pour se focaliser sur les tests plutôt que sur le code testé, on va utiliser un projet excessivement simple, avec 2 fichiers .java :

structure du projet

<pre><code><?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
          xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
     <modelVersion>4.0.0</modelVersion>

     <groupId>fr.younup</groupId>
     <artifactId>TryApprovalTestsWithJava</artifactId>
     <version>1.0-SNAPSHOT</version>

     <dependencies>
          <dependency>
               <groupId>com.approvaltests</groupId>
               <artifactId>approvaltests</artifactId>
               <version>14.0.0</version>
          </dependency>
          <dependency>
               <groupId>org.junit.jupiter</groupId>
               <artifactId>junit-jupiter</artifactId>
               <version>5.8.2</version>
               <scope>test</scope>
          </dependency>
     </dependencies>

     <properties>
          <maven.compiler.source>17</maven.compiler.source>
          <maven.compiler.target>17</maven.compiler.target>
     </properties>

</project>
</code></pre>

La classe à tester

La classe à tester, <span class="css-span">Candidate</span>, est aussi simple que la structure du projet. C'est un simple <span class="css-span">record</span> avec un unique champ :

<pre><code>package fr.younup;

public record Candidate(String name) {
}
</code></pre>

Une méthode <span class="css-span">public String toString()</span> est automatiquement générée par le compilateur. C'est parfait pour essayer ApprovalTests, vous comprendrez pourquoi dans la suite.

Notre premier test

Notre classe étant un <span class="css-span">record</span>, il y a peu de chance que son constructeur et sa méthode <span class="css-span">toString()</span> soient bogués, mais cela ne nous empêche pas d'écrire un test.

Le principe d'un test avec ApprovalTests est de construire un objet, de le manipuler et ensuite de le "vérifier". Cette vérification consiste à générer une chaine de caractères représentant l'objet et à la comparer à une chaine de caractères de référence, qu'on a préalablement "approuvé" (d'où le nom du framework). Vous comprenez maintenant pourquoi utiliser <span class="css-span">record</span> qui génère automatiquement une méthode <span class="css-span">toString()</span> est bien pratique pour nos essais ! La phase "manipulation" de l'objet après la construction sera très réduite (on peut même dire qu'elle est inexistante), mais ce n'est pas grave car on peut quand même illustrer le fonctionnement du framework.

Les deux chaines de caractères sont stockées dans deux fichiers et ApprovalTests fait simplement un <span class="css-span">diff</span> entre ces fichiers. Le résultat du <span class="css-span">diff</span> indique si le test réussit ou échoue.

Voici notre premier code de test :

<pre><code>package fr.younup;

import org.approvaltests.Approvals;
import org.junit.jupiter.api.Test;

public class TestCandidate {
   @Test    
     void candidate() {
       Candidate candidate = new Candidate("John Doe");
       Approvals.verify(candidate);
   }
}
</code></pre>

La première exécution des tests va échouer et on aura l'erreur suivante dans la console :

<pre><code>java.lang.Error: Failed Approval  
     Approved:D:\TryApprovalTestsWithJava\.\src\test\java\fr\younup\TestCandidate.candidate.approved.txt       Received:D:\TryApprovalTestsWithJava\.\src\test\java\fr\younup\TestCandidate.candidate.received.txt    

          at org.approvaltests.approvers.FileApprover.fail(FileApprover.java:57)    
          [...]
          at org.approvaltests.Approvals.verify(Approvals.java:55)    
          at fr.younup.TestCandidate.candidate(TestCandidate.java:11)  
          [...]
</code></pre>

Les deux fichiers ont été générés à côté de la classe de test et leurs noms dérivent des noms de la classe de test et de la méthode de test. Le fichier <span class="css-span">TestCandidate.candidate.approved.txt</span> est la référence et <span class="css-span">TestCandidate.candidate.received.txt</span> contient la chaine de caractères obtenue par la méthode <span class="css-span">verify()</span>. Comme le fichier <span class="css-span">TestCandidate.candidate.approved.txt</span> n'existe pas, la comparaison de fichiers échoue forcément.

ApprovalTests ouvre automatiquement notre utilitaire de <span class="css-span">diff</span> avec ces deux fichiers pour les comparer facilement. En vrai, il essaye un ensemble de <span class="css-span">Reporters</span>, correspondant à des outils classiques de <span class="css-span">diff</span>, et espère en trouver un.

C'est TortoiseMerge qui s'ouvre sur mon PC :

tortoise merge approved vide

Le contenu <span class="css-span">TestCandidate.candidate.received.txt</span> correspond bien au résultat attendu. On l'utilise pour remplir le fichier <span class="css-span">TestCandidate.candidate.approved.txt</span> :

tortoise merge use whole file

La seconde exécution des tests va réussir :

junit success

Le fichier <span class="css-span">TestCandidate.candidate.received.txt</span> est alors automatiquement supprimé. En effet, il n'est conservé que si le test correspondant échoue.

Cassons les tests

Changeons la classe Candidate pour avoir 2 champs au lieu d'un seul :

<pre><code>package fr.younup;
public record Candidate(String firstName, String lastName) {
}
</code></pre>

Modifions aussi les tests, histoire qu'il compile :

<pre><code>@Test
void candidate() {    
     Candidate candidate = new Candidate("John", "Doe");
     Approvals.verify(candidate);
}
</code></pre>

Si on relance les tests, le fichier <span class="css-span">TestCandidate.candidate.received.txt</span> va bien contenir le nouveau champ, mais le fichier <span class="css-span">TestCandidate.candidate.approved.txt</span> correspondra toujours à l'ancienne version de la classe. Les tests échouent donc et le <span class="css-span">diff</span> s'ouvre à nouveau :

tortoise merge fichiers différents

On peut accepter les changements et relancer les tests, qui réussiront à nouveau.

Vous avez compris le principe

Nous avons vu les bases d'ApprovalTests. Il existe des fonctions de vérification plus poussées mais elles fonctionnent sur le même principe : après manipulation d'objets, on génère une chaine de caractères et on la compare avec une chaine de références.

Dans la suite, nous allons essayer d'autres fonctions de comparaison un peu plus avancées.

Tester une liste d'objets

Si on peut tester un objet, il semble évident qu'on peut tester une liste d'objets, car une liste est un objet. On rajoute une méthode dans notre classe <span class="css-span">TestCandidate</span> :
<pre><code>@Test
void candidates() {
   ArrayList<Candidate> candidates = new ArrayList<>();
   candidates.add(new Candidate("John", "Doe"));
   candidates.add(new Candidate("Jean", "Bonneau"));
   candidates.add(new Candidate("Harry", "Cover"));
   Approvals.verify(candidates);
}
</code></pre>

Rien de bien sorcier ici.

Les fichiers de sortie sont <span class="css-span">TestCandidate.candidates.approved.txt</span> et <span class="css-span">TestCandidate.candidates.received.txt</span>. Après acceptation, ils contiennent :

<pre><code>
[Candidate[firstName=John, lastName=Doe], Candidate[firstName=Jean, lastName=Bonneau], Candidate[firstName=Harry, lastName=Cover]]
</code></pre>

Tester des combinaisons

Plutôt que choisir manuellement des couples "prénom / nom", on peut utiliser la capacité d'ApprovalTests à générer et vérifier des combinaisons.

Voici une nouvelle méthode de test, accompagnée de sa fonction de génération de combinaisons :

<pre><code>@Test
void combinations() {
   CombinationApprovals.verifyAllCombinations(
           this::generateCandidate,
           new String[]{"Jean", "Jeanne"},
           new String[]{"Dupont", "Martin"}
   );}
Candidate generateCandidate(String firstName, String lastName) {
   return new Candidate(firstName, lastName);
}
</code></pre>

Les fichiers de sortie, <span class="css-span">TestCandidate.combinations.approved.txt</span> et <span class="css-span">TestCandidate.combinations.received.txt</span> contiennent :

<pre><code>[Jean, Dupont] => Candidate[firstName=Jean, lastName=Dupont]
[Jean, Martin] => Candidate[firstName=Jean, lastName=Martin]
[Jeanne, Dupont] => Candidate[firstName=Jeanne, lastName=Dupont]
[Jeanne, Martin] => Candidate[firstName=Jeanne, lastName=Martin]
</pre></code>

Fichiers à versionner

Il faut versionner tous les fichiers <span class="css-span">*.approved.txt</span> car ils font partie des tests. Ils décrivent les résultats et les autres membres de l'équipe, ainsi que votre CI, en auront besoin pour exécuter les tests.

A propos de CI, vous vous demandez ce qu'il se passe quand les tests échouent et que l'outil de <span class="css-span">diff</span> s'ouvre ? Bonne question ! En fait, il ne se lance pas. Plus de détails ici : "Build Machines and Continuous Integration servers".

Au passage, il parait que vous aurez peut-être besoin d'ajouter <span class="css-span">*.approved.* binary</span> à votre fichier <span class="css-span">.gitattributes</span> pour éviter des erreurs sur les fins de ligne.

Conclusion

J'ai vraiment bien aimé ce framework. J'ai écris des tests vraiment chouettes, beaucoup plus simples et lisibles qu'avec des assertions "classiques" de tests unitaires.

ApprovalTests ne remplace pas les tests unitaires avec Catch2 ou JUnit ou [insérer le nom de votre framework ici], il propose juste d'autres méthodes pour tester votre code. Il est très efficace pour des classes qui génèrent de la donnée (surtout si c'est du texte). C'est aussi très adapté pour du code existant qu'on sait être fonctionnel, comme le Gilded Rose de la vidéo évoquée au début de l'article, car on peut approuver un ensemble de données d'un coup, sans avoir à écrire des dizaines d'assertions.

Vous avez maintenant un outil supplémentaire pour tester votre code. Faites-en bon usage !

No items found.
ça t’a plu ?
Partage ce contenu
Pierre

Que la vie de Pierre, expert embarqué Younup, serait terne sans les variadic templates et les fold expressions de C++17. Heureusement pour lui, Python a tué l'éternel débat sur l’emplacement de l’accolade : "alors, à la fin de la ligne courante ou au début de la ligne suivante ?"

Homme de terrain, il est aussi à l’aise au guidon de son VTT à sillonner les chemins de forêt, dans une salle de concert de black metal ou les mains dans les soudures de sa carte électronique quand il doit déboguer du code (bon ça, il aime moins quand même !)

Son vœu pieux ? Il hésite encore... Faire disparaitre le C embarqué au profit du C++ embarqué ? Ou stopper la génération sans fin d'entropie de son bureau.