Tester votre code autrement avec ApprovalTests

pgradot

Pierre Gradot

Posted on July 22, 2022

Tester votre code autrement avec ApprovalTests

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 projen

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>
Enter fullscreen mode Exit fullscreen mode

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) {
}
Enter fullscreen mode Exit fullscreen mode

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);
    }
}
Enter fullscreen mode Exit fullscreen mode

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)
    [...]
Enter fullscreen mode Exit fullscreen mode

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 :

tortoise merge approved vide

Le contenu TestCandidate.candidate.received.txt correspond bien au résultat attendu. On l'utilise pour remplir le fichier TestCandidate.candidate.approved.txt :

tortoise merge use whole filen

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

junit success

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) {
}
Enter fullscreen mode Exit fullscreen mode

Modifions aussi les tests, histoire qu'il compile :

@Test
void candidate() {
    Candidate candidate = new Candidate("John", "Doe");
    Approvals.verify(candidate);
}
Enter fullscreen mode Exit fullscreen mode

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 :

Tortoise merge fichiers différentsn

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);
}
Enter fullscreen mode Exit fullscreen mode

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]]
Enter fullscreen mode Exit fullscreen mode

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);
}
Enter fullscreen mode Exit fullscreen mode

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] 

Enter fullscreen mode Exit fullscreen mode

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 !

💖 💪 🙅 🚩
pgradot
Pierre Gradot

Posted on July 22, 2022

Join Our Newsletter. No Spam, Only the good stuff.

Sign up to receive the latest update from our blog.

Related