Activez le simulateur C'est parti !
Actualités

Refactorer le code legacy intestable avec les Approval Tests

Avr 10, 2024 · 13 minutes read

Dans son livre, Working Effectively with Legacy Code, Michael Feathers définit un code legacy comme un code “non testé”. 

Vous connaissez les conséquences que peut être avoir un code legacy. 

En tout bon crafter que vous êtes, vous souhaitez refactorer ce code legacy et lui appliquer une couche de tests.

Sauf que vous êtes face à un code que vous ne connaissez pas, illisible, incompréhensible et vous n’avez même pas d’expert métier pour vous expliquer le domaine… 😰
Comment faire ? Les Approval Tests sont là pour vous aider !

Grâce à cette librairie multi-plateforme, sécurisez la refonte de votre application grâce à un code couvert par des tests, plus fiable et plus sûr. La cerise sur le gâteau c’est que tout ça c’est rapide, très facile à apprendre et à utiliser.

Refactorer ? Qu’est-ce que cela veut dire ?

Refactorer, c’est changer la structure du code sans changer son comportement afin de l’améliorer et de le rendre plus performant et durable.
Ceci peut passer par le fait de renommer un attribut, déplacer une ligne de code, extraire une méthode, etc. 

Le Refactoring n’est pas une tâche dans votre “tableau de Scrum”, mais une pratique de développement qu’il faut faire de manière systématique. Comme dit Uncle Bob, le Refactoring c’est comme se laver les mains. On le fait de manière récurrente sans le planifier ou sans y penser.

Refactorer un code n’a pas vraiment besoin d’outil en particulier, mais il y a des environnements de développements (IDE) qui nous proposent des outils afin de rendre cette tâche importante plus facile. Par exemple, les IDE fournies par JetBrains sont réputées pour des adeptes de refactoring.

Mais refactorer le code nécessite une pré-requis qui est d’abord de le faire découvrir par des tests. Si on commence à refactorer un code sans le couvrir par les tests, on n’est pas à l’abri de le casser et de créer des bugs. C’est malheureusement une mauvaise pratique chez beaucoup de développeurs.

Si vous voulez en savoir plus sur les différentes méthodes de Refactoring je vous conseille de lire le livre de Martin Fowler qui porte le même nom.

Écrire des tests avant de refactorer c’est bien beau, mais lorsqu’on ne comprend pas le code, c’est quasiment mission impossible.

Pour atteindre cet objectif, on peut utiliser une technique qui s’appelle Golden Master. 

Qu’est-ce que la technique du Golden Master ?

La technique du Golden Master consiste à envoyer des données en entrée (input) d’une méthode, peu importe sa taille, ses paramètres et ses dépendances, et de collecter les réponses qu’on reçoit (output).

Après avoir collecté assez de données (lorsque le code est totalement couvert par les tests), il est temps de prendre une image de ces résultats pour pouvoir commencer à “refactorer” le code.
Après chaque changement de structure dans le code, on relance les tests avec les mêmes inputs que nous avons collectées. Si les outputs sont identiques à l’image de départ, alors cela signifie que nos modifications n’ont rien changé à la logique de notre programme. Rien n’a été cassé dans notre code, on peut avancer !
Si le résultat diffère de l’image de départ, il faut alors revenir en arrière pour retomber sur des tests verts. 

Les Approval Test est un projet open source et une librairie qui facilite grandement la mise en place du Golden Master. Avec les Approval Tests vous pouvez générer plusieurs cas de tests afin de bien couvrir le code de production et éviter des mauvaises surprises et tout ça en une seule ligne de code (emoji surpris).

Allez, il est temps de passer aux choses concrètes, avec une démonstration !

Comment ajouter Approval Tests à notre projet ?

L’ajout d’Approval Test à votre projet est très simple, il suffit de choisir le platforme qui vous intéresse parmi .Net, C++, Java, Lua, NodeJS, Objective-C, Perl ou Python en passant par un gestionnaire de dépendances ou en téléchargeant les packages nécessaires.

Par exemple pour un projet Java vous pouvez utiliser Maven ou Gradle pour ajouter la dépendance comme ci après :

Maven

<dependency>
    <groupId>com.approvaltests</groupId>
    <artifactId>approvaltests</artifactId>
    <version>11.2.3</version>
</dependency>

Gradle

dependencies {
    testImplementation(« com.approvaltests:approvaltests:11.2.3 »)
}

Mise en place du premier Approval Test ?

Pour apprendre à utiliser Approval test, essayons d’écrire un test classique en utilisant JUint et AssertJ :

@Test
void find_first_available_recruiter_who_can_test_candidate() {
    // Given
    var interview = new PlanInterview(
      candidates, recruiters, interviews);

    // When
    Interview plannedInterview = interview.plan(« 123 », of(2021, 2, 21));

    // Then
    assertThat(plannedInterview).isEqualTo(expectedInterview());
}

La première étape sera pour nous de trouver les inputs et les séparer du code. C’est facile :

@Test
void find_first_available_recruiter_who_can_test_candidate() {
    String candidateId = « 123 »;
    LocalDate interviewDate = of(2021, 2, 21);

    // Given
    var interview = new PlanInterview(
      candidates, recruiters, interviews);

    // When
    Interview plannedInterview =
            interview.plan(candidateId, interviewDate);

    // Then
    assertThat(plannedInterview).isEqualTo(expectedInterview());
}

Avec Approval Tests je n’ai pas besoin de la partie “Then”. C’est lui qui fera ça pour moi. Je peux donc supprimer cette partie.

Ensuite, je vais extraire les parties “Given” et “When” pour créer une fonction à tester :

@Test
void find_first_available_recruiter_who_can_test_candidate() {
    String candidateId = « 123 »;
    LocalDate interviewDate = of(2021, 2, 21);

    extracted(candidateId, interviewDate);
}

private Interview extracted(String candidateId, LocalDate interviewDate) {
    // Given
    var interview = new PlanInterview(
      candidates, recruiters, interviews);

    // When
    return interview.plan(candidateId, interviewDate);
}

Maintenant il suffit d’appeler la méthode “verifyAllCombinations“ d’Approval Tests pour avoir l’équivalent du premier test classique que nous avons écrit plus haut.

Avant de faire ça, si on regarde la signature de cette méthode, on voit qu’elle prend comme attribut une fonction (extracted) et un ensemble d’inputs sous forme de tableau :

public static <IN1, IN2, OUT> void verifyAllCombinations(Function2<IN1, IN2, OUT> call, IN1[] parameters1, IN2[] parameters2) {
    …
}

Ce qui signifie que nous devons transformer nos inputs en tableau pour pouvoir les utiliser. Ce qui nous donne le code suivant :

@Test
void find_first_available_recruiter_who_can_test_candidate() {
    String[] candidateId = {« 123 »};
    LocalDate[] interviewDate = {of(2021, 2, 21)};

    verifyAllCombinations(this::extracted, candidateId, interviewDate);
}

Vous allez me dire “ mais pourquoi se donner autant de mal pour écrire l’équivalent d’un test qui fonctionnait ? “
La réponse c’est qu’ avec Approval Test on peut lancer pleins d’inputs sur un seul test. Je vous rappelle que nos inputs sont maintenant des tableaux de données et c’est exactement ce dont nous avons besoin pour utiliser la technique Golden Master. 

Lancement d’Approval Tests :

Le première fois qu’on lance les Approvals Tests, c’est toujours rouge. C’est parce qu’on a besoin de lancer les tests au moins une fois pour récolter les outputs dont on a parlés pour le Golden Master.  

En clair, pas d’image de comparaison = tests rouges

Si vous regardez dans l’arborescence de votre projet vous allez voir que 2 nouveaux fichiers ont été générés :

  • Un fichier nommé “received“
  • Un fichier nommé “approved”

Ce sont vos Golden Master     :

Maintenant il suffit de copier le contenu du fichier “received” dans le fichier “approved” pour passer les tests au vert. Vous pouvez aussi supprimer le fichier “approved” et renommer le fichier “received” pour “approved”.

Voyons voir à quel point ce premier test était efficace. Pour cela, nous allons regarder la couverture des tests à l’aide d’IntelliJ. Il suffit de lancer les tests avec ce bouton :

Et voici le résultat :

On peut constater que le seul test qu’on a écrit ne couvre pas toutes les lignes de notre code, car il faut plus d’inputs. Nous devons donc trouver plus de données. Pour ça, on peut demander l’aide d’un expert métier, ou regarder la base de données ou encore bien lire le code. Si ce n’est pas possible, essayez de mettre des données aléatoires qui vous semblent pertinentes comme par exemple “null”, chaine de caractère vide, des chiffres -1, 0, 1, etc.

Notre test va donc ressembler à ça :

@Test
void find_first_available_recruiter_who_can_test_candidate() {
    String[] candidateId = {« 123 », « 456 », « 789 », null, «  »};
    LocalDate[] interviewDate = {
            of(2021, 2, 21),
            of(2021, 2, 20),
            of(2021, 2, 22),
            of(1300, 2, 21),
            of(3200, 2, 21),
            null
    };

    verifyAllCombinations(this::extracted, candidateId, interviewDate);
}

Lorsque vous lancez de nouveau les tests, ils apparaissent rouges. C’est tout à fait normal ! En effet, vous venez d’ajouter de nouveaux inputs. 

On va alors copier les nouvelles données que nous avons reçues dans le fichier “received” et les coller dans “approved”. Les tests passeront de nouveau au vert. 

Regardons maintenant la couverture du code par nos nouveaux tests :

Parfait, le code est bien couvert par nos tests. On peut commencer à refactorer notre code !

Maintenant regardons un peu ce qu’on a obtenu dans le fichier « approved » :

[123, 2021-02-21] => use_case.AnyRecruiterFoundException: null
[123, 2021-02-20] => Interview{candidate=Candidate{skills=[.Net, Java, PHP, JS], name=’Alex’}, recruiter=Recruiter{recruiterId=’002′, skills=[Java, .Net, PHP, JS], availabilities=[2021-02-22], name=’Mary’}, interviewDate=2021-02-20}
[123, 2021-02-22] => Interview{candidate=Candidate{skills=[.Net, Java, PHP, JS], name=’Alex’}, recruiter=Recruiter{recruiterId=’002′, skills=[Java, .Net, PHP, JS], availabilities=[2021-02-20], name=’Mary’}, interviewDate=2021-02-22}
[123, 1300-02-21] => use_case.AnyRecruiterFoundException: null
[123, 3200-02-21] => use_case.AnyRecruiterFoundException: null
[123, null] => use_case.AnyRecruiterFoundException: null
[456, 2021-02-21] => use_case.AnyRecruiterFoundException: null
[456, 2021-02-20] => Interview{candidate=Candidate{skills=[JS], name=’Bob’}, recruiter=Recruiter{recruiterId=’001′, skills=[PHP, JS], availabilities=[], name=’Emma’}, interviewDate=2021-02-20}
[456, 2021-02-22] => Interview{candidate=Candidate{skills=[JS], name=’Bob’}, recruiter=Recruiter{recruiterId=’002′, skills=[Java, .Net, PHP, JS], availabilities=[2021-02-20], name=’Mary’}, interviewDate=2021-02-22}
[456, 1300-02-21] => use_case.AnyRecruiterFoundException: null
[456, 3200-02-21] => use_case.AnyRecruiterFoundException: null
[456, null] => use_case.AnyRecruiterFoundException: null
[789, 2021-02-21] => use_case.AnyRecruiterFoundException: null
[789, 2021-02-20] => use_case.AnyRecruiterFoundException: null
[789, 2021-02-22] => use_case.AnyRecruiterFoundException: null
[789, 1300-02-21] => use_case.AnyRecruiterFoundException: null
[789, 3200-02-21] => use_case.AnyRecruiterFoundException: null
[789, null] => use_case.AnyRecruiterFoundException: null
[null, 2021-02-21] => java.lang.NullPointerException: null
[null, 2021-02-20] => java.lang.NullPointerException: null
[null, 2021-02-22] => java.lang.NullPointerException: null
[null, 1300-02-21] => java.lang.NullPointerException: null
[null, 3200-02-21] => java.lang.NullPointerException: null
[null, null] => java.lang.NullPointerException: null
[, 2021-02-21] => java.lang.NullPointerException: null
[, 2021-02-20] => java.lang.NullPointerException: null
[, 2021-02-22] => java.lang.NullPointerException: null
[, 1300-02-21] => java.lang.NullPointerException: null
[, 3200-02-21] => java.lang.NullPointerException: null
[, null] => java.lang.NullPointerException: null

Trop fort cet outil ! Avec 1 test et 1 ligne de code j’ai obtenu 30 tests d’un coup. Cela correspond à la combinaison de toutes les données que je lui ai passé en input. 

Pré-requis de mise en place d’Approval Test :

  • Il est conseillé d’avoir l’outil WinMerge (ou un autre comme Kdiff) pour faciliter le passage entre les fichiers “received” et “approved” et aussi pour comparer la différence entre les données si jamais on fait une régression en faisant notre Refactoring :
  • Il faut implémenter la méthode toString dans tous les objets qui vont être testés par Approval Tests. Sinon on obtient des références à des objets et nos tests sont toujours faux : 
@Override
    public String toString() {
        return « Candidate{ » +
                « skills= » + skills +
                « , name=' » + name + ‘\ » +
                ‘}’;
    }

REX :

Personnellement je ne m’amuse pas à refactorer du code pour me faire plaisir. Si une partie du code fonctionne correctement, même s’il est illisible, pas testé ou long, tant que je n’ai pas à le modifier, je ne vais pas le refactorer. Il faut toujours garder en mémoire que le refactoring a un coût et celui-ci n’est pas négligeable surtout lorsqu’on est face à une longue méthode, par exemple.

Par contre, si jamais je dois modifier ou améliorer une partie du code et que celle-ci n’est pas couverte (ou pas complètement) par des tests j’en profite pour utiliser les Approval Tests. Une fois, cette partie du code couverte par des tests, je peux ensuite commencer à le refactorer, à le casser en plus petits morceaux, afin de le rendre plus maîtrisable. Je vais aussi mettre en place des nouvelles classes, des méthodes et de nouveaux tests. 

Mon refactoring est terminé. Grâce à mes Approval Tests, je sais que je n’ai rien cassé dans mon code. Je peux maintenant commencer à les supprimer, puisque j’ai couvert mon code par d’autres types de tests (unitaires, intégrations, acceptances, etc.)

Cette procédure peut sembler coûteuse au niveau du temps. Cependant, il m’est arrivé plusieurs fois de refactorer un code non-testé et de créer des bugs, voire des problèmes de prod. J’ai donc dû revenir en arrière, ce qui m’a parfois fait perdre une journée complète de travail. 

Les tests sont donc utiles et ne sont en aucun cas négligeables. Passer du temps à écrire des tests est un gain de temps pour le maintien de mes produits sur le long terme. 

Conseils :

  • Les Approvals Tests ne sont pas auto-suffisants. Il faut créer des tests unitaires, intégration, etc. pour obtenir un résultat vraiment fiable
  • Il ne faut pas se fier à un code qui est couvert à 100% par les tests. Pour cela, essayez d’ajouter du “Mutation Testing”. Vous pouvez le faire à la main, en modifiant des conditions ou des valeurs dans votre code. Si après avoir modifié certaines logiques du code, les tests sont toujours verts, c’est que vous n’avez pas de tests fiables
  • Ne pas refactorer un code qui fonctionne correctement si on ne doit pas lui ajouter/supprimer une fonctionnalité

FAQ :

  • Peut-on tester une méthode qui renvoi void ? oui mais cela ne va pas vraiment nous aider à faire un Golden Master car on n’obtient rien en output.
  • Peut-on moquer les dépendances externes ? oui vous pouvez très bien utiliser une librairie de bouchon comme par exemple Mockito pour Java.

Repos GIT :

Sepehr Namdar
Sepehr Namdar