Tester votre code autrement avec ApprovalTests
Tags: programming, testing, java
Published: 2022-07-22 12:25:24


Il y a quelques temps, j'ai regardé une vidéo hypnotique d'[Emily Bache](https://twitter.com/emilybache), dans laquelle elle travaille sur le [kata](https://fr.wikipedia.org/wiki/Kata_(programmation)) dit ["Gilded Rose"](https://github.com/emilybache/GildedRose-Refactoring-Kata). 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](https://approvaltests.com/). 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](https://github.com/catchorg/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](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/vuhopkf1b2rjs0kad3ap.png)

Dans le `pom.xml`, on rajoute les dépendances pour récupérer JUnit et ApprovalTests :

```xml
<?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`](https://docs.oracle.com/en/java/javase/17/language/records.html) avec un unique champ :

```java
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 :

```java
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 :

```raw
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]( https://approvaltestscpp.readthedocs.io/en/latest/generated_docs/Reporters.html).

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

![tortoise merge approved vide](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/l94s5n8q0m0gezm75lfy.png)

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](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/929fk8je1zm21a7p13pd.png)

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

![junit success](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/uh2hq4u27ney7iw8rd8b.png)

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 :

```java
package fr.younup;

public record Candidate(String firstName, String lastName) {
}
```

Modifions aussi les tests, histoire qu'il compile :

```java
@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 :

![Tortoise merge fichiers différentsn](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/amcuyd4goclpnn3bx55b.png)

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

```java
@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 :

```raw
[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 :

```java
@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 :

```raw
[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"](https://approvaltestscpp.readthedocs.io/en/latest/generated_docs/BuildMachinesAndCI.html).

Au passage, [il parait que vous aurez peut-être besoin](https://github.com/approvals/approvaltests.java#approved-file-artifacts) 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 !