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
:
Dans le pom.xml
, on rajoute les dépendances pour récupérer JUnit et ApprovalTests :
<?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>
La classe à tester
La classe à tester, Candidate
, est aussi simple que la structure du projet. C'est un simple record
avec un unique champ :
package fr.younup;
public record Candidate(String name) {
}
Une méthode public String toString()
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 record
, il y a peu de chance que son constructeur et sa méthode toString()
soient boguées, 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 record
qui génère automatiquement une méthode toString()
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 diff entre ces fichiers. Le résultat du diff indique si le test réussit ou échoue.
Voici notre premier code de test :
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);
}
}
La première exécution des tests va échouer et on aura l'erreur suivante dans la console :
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)
[...]
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 TestCandidate.candidate.approved.txt
est la référence et TestCandidate.candidate.received.txt
contient la chaine de caractères obtenue par la méthode verify()
. Comme le fichier TestCandidate.candidate.approved.txt
n'existe pas, la comparaison de fichiers échoue forcément.
ApprovalTests ouvre automatiquement notre utilitaire de diff avec ces deux fichiers pour les comparer facilement. En vrai, il essaye un ensemble de Reporters
, correspondant à des outils classiques de diff, et espère en trouver un.
C'est TortoiseMerge qui s'ouvre sur mon PC :
Le contenu TestCandidate.candidate.received.txt
correspond bien au résultat attendu. On l'utilise pour remplir le fichier TestCandidate.candidate.approved.txt
:
La seconde exécution des tests va réussir :
Le fichier TestCandidate.candidate.received.txt
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 :
package fr.younup;
public record Candidate(String firstName, String lastName) {
}
Modifions aussi les tests, histoire qu'il compile :
@Test
void candidate() {
Candidate candidate = new Candidate("John", "Doe");
Approvals.verify(candidate);
}
Si on relance les tests, le fichier TestCandidate.candidate.received.txt
va bien contenir le nouveau champ, mais le fichier TestCandidate.candidate.approved.txt
correspondra toujours à l'ancienne version de la classe. Les tests échouent donc et le diff s'ouvre à nouveau :
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 TestCandidate
:
@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);
}
Rien de bien sorcier ici.
Les fichiers de sortie sont TestCandidate.candidates.approved.txt
et TestCandidate.candidates.received.txt
. Après acceptation, ils contiennent :
[Candidate[firstName=John, lastName=Doe], Candidate[firstName=Jean, lastName=Bonneau], Candidate[firstName=Harry, lastName=Cover]]
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 :
@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);
}
Les fichiers de sortie, TestCandidate.combinations.approved.txt
et TestCandidate.combinations.received.txt
contiennent :
[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]
Fichiers à versionner
Il faut versionner tous les fichiers *.approved.txt
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 CI, vous vous demandez ce qu'il se passe quand les tests échouent et que l'outil de diff 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 *.approved.* binary
à votre fichier .gitattributes
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 !