Skip to content

Vous ne verrez plus les tests unitaires de la même manière …

TL;DR: Si vous pensez maîtriser le concept de test unitaire, lisez cet article jusqu’à la fin, vous serez très probablement surpris.

D’abord une notion de vocabulaire importante :

Un SUT ou encore « System under test » désigne un système qui fait l’objet d’un test vérifiant l’exactitude d’une opération.

On définit alors un test unitaire ainsi :

Un test unitaire est un test vérifiant en isolation l’exactitude d’une opération d’un SUT.

La notion la plus importante est la notion d’isolation.

Faîtes un petit jeu avec vos collègues développeurs; retournez-vous et posez-leur cette question :

« Que signifie le terme ‘en isolation’ dans cette définition ? »

99% si ce n’est pas tous vous répondront :

‘En isolation’ signifie que tous les réels collaborateurs du SUT ne doivent pas être traversés par le test; de façon à se focaliser uniquement sur le SUT lui-même. Il faut alors « simuler » les collaborateurs lors du  test.

Prenons un exemple pour illustrer leur raisonnement :


public class ATest {

    // Tests unitaire de l'opération 'foo' de A
    @Test
    public void shouldFoo() {
        B stubOfB = () -> 3; // Le collaborateur B est "mocké" (en réalité stubbé mais "mock" par abus de langage) 
        assertEquals(3, new A(stubOfB).foo());
    }

}

// A est le SUT du test 'shouldFoo()'
public class A {

    private final B b;

    public A(B b) {
        this.b = b;
    }

    public int foo() {
        return b.bar();
    }

}

public interface B {
   int bar();
}

// B est entre autre le collaborateur de A
public class RealB implements B {

    public int bar() {
        //... retourne un nombre généré par le saint-esprit
    }

}

La réelle implémentation de B, collaborateur de A, n’est donc pas traversée par le test ‘shouldFoo()‘, comme spécifié par la définition donnée par tes collègues.
La conséquence est que chaque réelle implémentation (A et RealB) devra avoir sa propre suite de tests.

Cette définition est très présente dans l’esprit de nombreux développeurs et est même portée par un célèbre livre intitulé : « Growing Object-Oriented Software Guided By Tests » de Steve Freeman et Nat Price.
On y apprend à faire émerger des rôles; une interface de collaborateur représentant un rôle; et de les tester séparément afin de mieux expliciter les concepts et les interactions entre les objets.

Si vous êtes friands de ce type d’interprétation du terme « En isolation », sachez qu’il y a un problème majeur avec cela, bien subtil, qui à terme détourne les développeurs de leur tâche la plus professionnelle, à savoir l’écriture de tests unitaires de sorte à maîtriser ce qui est livré en production.

Maintenant que vous maîtrisez les concepts de SUT et de collaborateurs, prenons un exemple plus concret mettant en valeur une problématique majeure.
Il s’agit ici d’organiser une course d’animaux (en l’occurrence entre un guépard et un cheval) et de remonter la vitesse maximale de chacun ainsi que le classement final :


public class AnimalsRaceTest {

    @Test
    public void shouldRunARaceBetweenSomeAnimals() {
        Cheetah cheetah = new Cheetah();
        Horse horse = new Horse();
        Set<RaceResult> ranking = new Race().start(Arrays.asList(cheetah, horse));
        assertEquals(new RaceResult("Cheetah", 110), Iterables.get(ranking, 0));
        assertEquals(new RaceResult("Horse", 70), Iterables.get(ranking, 1));
    }

}

public class Race {

    public Set<RaceResult> start(List<Animal> animals) {
        return animals
                .stream()
                .map(animal -> {
                    if (animal instanceof Cheetah)
                        return new RaceResult("Cheetah", 110);
                    return new RaceResult("Horse", 70);
                })
                .collect(
                        Collectors.toCollection(() ->
                                new TreeSet<>((RaceResult r1, RaceResult r2) -> Integer.compare(r2.getMaxSpeed(), r1.getMaxSpeed())))
                );
    }

}

public final class RaceResult {

    private final String animalType;
    private final int maxSpeed;

    public RaceResult(String animalType, int maxSpeed) {
        this.maxSpeed = maxSpeed;
        this.animalType = animalType;
    }

    public int getMaxSpeed() {
        return maxSpeed;
    }

    @Override
    public boolean equals(Object o) {
        if (this == o) return true;
        if (o == null || getClass() != o.getClass()) return false;
        RaceResult that = (RaceResult) o;
        return maxSpeed == that.maxSpeed &&
                Objects.equals(animalType, that.animalType);
    }

    @Override
    public int hashCode() {
        return Objects.hash(animalType, maxSpeed);
    }

}

public abstract class Animal {
}

public class Cheetah extends Animal {
}

public class Horse extends Animal { 
}

Le SUT ici est l’instance de Race.

Le teste passe avec succès.
Cependant, un dev quelque peu senior entre dans la pièce et se dit :
« Oula ! Y’a du refactoring à faire; abats les instanceof et le procédural ! »

Son implémentation souhaitée est celle-ci :


public class Race {

    public Set<RaceResult> start(List<Animal> animals) {
        return animals
                .stream()
                .map(animal -> new RaceResult(animal.getName(), animal.run()))
                .collect(Collectors.toCollection(() ->
                        new TreeSet<>(
                                (RaceResult r1, RaceResult r2) ->
                                        Integer.compare(r2.getMaxSpeed(), r1.getMaxSpeed())))
                );
    }

}

public final class RaceResult {

    private final String animalType;
    private final int maxSpeed;

    public RaceResult(String animalType, int maxSpeed) {
        this.maxSpeed = maxSpeed;
        this.animalType = animalType;
    }

    public int getMaxSpeed() {
        return maxSpeed;
    }

    @Override
    public boolean equals(Object o) {
        if (this == o) return true;
        if (o == null || getClass() != o.getClass()) return false;
        RaceResult that = (RaceResult) o;
        return maxSpeed == that.maxSpeed &&
                Objects.equals(animalType, that.animalType);
    }

    @Override
    public int hashCode() {
        return Objects.hash(animalType, maxSpeed);
    }

}

abstract class Animal {

    public abstract String getName();

    public abstract int run();

}

public class Cheetah extends Animal {

    public String getName() {
        return "Cheetah";
    }

    public int run() {
        return 110;
    }
}

public class Horse extends Animal {

    public String getName() {
        return "Horse";
    }

    @Override
    public int run() {
        return 70;
    }
}

Hey ! Mais selon la définition du test unitaire énoncée plus haut, chaque objet doit être testé en isolation; et là ce n’est plus le cas !
En effet, le test unitaire shouldRunARaceBetweenSomeAnimals n’a pas trop l’air de se focaliser uniquement sur le SUT vu qu’il traverse le code des méthodes getName() et run() des classes dérivées de la classe Animal ! Il semble qu’il faille donc tester ces deux dernières séparément :


public class CheetahTest {
    @Test
    public void aCheetahShouldRunAtItsNaturalPace() {
        assertEquals(110, new Cheetah().run());
    }
}

public class HorseTest {
    @Test
    public void aHorseShouldRunAtItsNaturalPace() {
        assertEquals(70, new Horse().run());
    }
}

Avant d’aller plus loin, on stoppe tout de suite et on se pose les bonnes questions.
Faîtes le vide dans votre tête, oubliez tout et reprenez ce test, lisez le et dîtes-moi ce qu’il fait :


public class CheetahTest {
    @Test
    public void aCheetahShouldRunAtItsNaturalPace() {
        assertEquals(110, new Cheetah().run());
    }
}

Développeur : « Bah … il vérifie qu’un un guépard court à 110 à l’heure ».

Mentor : « Et…tu n’as pas l’impression qu’il manque le … contexte ?? Pourquoi faire courir un animal ?! »

Développeur : « C’est sûr que là si on ne voit que cette classe, on ne sait pas pourquoi, c’est trop générique ! »

Mentor : « Et que se passe-t’il lorsqu’un test n’est pas compréhensible à 100% ? »

Développeur : « On le touche pas ! On le laisse où il est, même s’il ne semble plus d’actualité ! »

Mentor : « Tout à fait, et du coup on n’a plus confiance en ses tests car non maîtrisés !
Et dis-moi, tu notes pas une redondance au niveau des vérifications par tes tests là ? Ton test du use case de la course et ton test focalisé sur le run d’un animal ? »

Développeur : « Oui je n’ai pas encore adapté le test ! Il faut désormais empêcher la méthode run d’être traversée par le test pour que le SUT (ici Race) soit en isolation !
Je pense faire un « mock » pour ça, et vérifier que la méthode run est appelée deux fois, tout en lui faisant retourner des valeurs adaptées et différentes ! »

Mentor : « Ahhh… on y vient ! Ne touche plus à rien !!!! 😉 »

Si t’as l’impression que tes suites de tests sont le reflet miroir du code de production, c’est qu’il y a un problème ! 

Mentor : « Regarde, ne touche pas au test shouldRunARaceBetweenSomeAnimals, et lance-le; que se passe-t’il ? »

Développeur : « Il passe … !!! »

Mentor : « Et oui il passe !! Car tu n’as fait que changer l’implémentation interne de ton algorithme grâce à ton refactoring ! Le détail d’implémentation qui consiste à avoir employé le polymorphisme au lieu des « instanceof » est masqué au test ! Les détails d’implémentation sont (doivent) être une boite noire pour lui et donc n’est pas affecté par ce type de changement ! »

Développeur : « C’est cool ! Mais malheureusement j’ai enfreint la règle ! Mon SUT n’est plus en isolation car il a besoin de l’algo réel des collaborateurs (Cheetah et Horse) pour fonctionner ! Mon test n’est plus U-N-I-T-A-I-R-E !

Mentor : « Approche … je vais te révéler un secret …. la définition énoncée plus haute par tes collègues est totalement fausse ! »

Développeur : « ?! Pourquoi ? »

Mentor : « En isolation ne s’applique pas au code de prod mais au test ! C’est le test qui doit s’exécuter en isolation des autres tests ! »

Développeur : « Ah bon ?? Mais encore ?? »

Mentor : « Un test est qualifié d’U-N-I-T-A-I-R-E s’il n’est aucunement affecté par le lancement d’un autre test; mais peut sans problème traverser plusieurs collaborateurs du SUT, du moment qu’on ne sort pas vers le monde externe (base de données, bibliothèque tierce, accès au disque dur etc. ) ! »

Développeur : « Un exemple peut-être ? »

Mentor : « Si tu as un premier test qui échange avec une base de données MySQL, et que ce test ajoute une entrée dans la table ‘Car’, il se pourrait qu’un prochain test soit affecté par cela et démarre avec une entrée ajoutée en base sans même le savoir ! »

Développeur : « Bah on peut faire un @Before avant chaque test et supprimer les données de la base. »

Mentor : « Oui, mais ce n’est donc plus un test unitaire car tu as été obligé d’effectuer une action manuelle (l’écriture de ce @Before) pour empêcher des side-effects ! »

Développeur : « Je comprends mieux ! Du coup, je supprime mes tests qui mettent le focus sur la classe Cheetah et Horse car ils sont déjà traversés par le code Race#start ? »

Mentor : « Tout à fait ! Les seuls tests unitaires qui ont du sens, sont les tests qui se focalisent sur l’exposition du use case; amenant un contexte au lecteur; à savoir la méthode Race#start !
Concernant tout ce qui est embarqué par cette méthode, autrement dit les détails d’implémentations, le test n’a pas à le savoir !
Un test unitaire doit être orienté « fonctionnel / métier », pour inclure ce fameux contexte, et non uniquement orienté « programmeur » ! »

Développeur : « Je comprends totalement !! Mais pourquoi personne ne faisait comme ça autour de moi ? »

Mentor : « Éternelle question … une fausse idée reçue; ils oublient qu’un test unitaire a pour but de refactorer son code et non de tester, et que du coup avoir des tests qui font effet miroir avec ton code de production influerait négativement sur ta capacité à refactorer sans rendre plusieurs tests obsolètes ! »

Développeur : « Hummm !! Vu comme ça, c’est vrai que j’ai pu faire mon refactoring de manière « safe » sans avoir peur de tout casser car le test du use case existait déjà ».

Mentor : « Regarde cette vidéo d’Uncle Bob; elle dure 3 minutes, tu vas voir les choses autrement ! »

Développeur : « Merci ! Ça a changé ma vision du dev !! »

Conclusion

  • « Unitaire » ne signifie pas « ciblant un fichier / une classe / une méthode ».
    « Unitaire » signifie  un test qui s’exécute de manière totalement indépendante NATURELLEMENT des autres tests.
    Ce n’est pas une unité au sens « unité de structure », mais au sens « unité de comportement / use case ».
    La nuance est énorme !
  • Un test unitaire PEUT traverser plusieurs réelles implémentations des collaborateurs du SUT, tant que celles-ci ne sortent pas en dehors du coeur du domaine métier.
    Dans ce cas-là, elles devront être doublées (stub par exemple).
    En général, ces collaborateurs ont émergé par le biais des étapes de refactoring du code, associées à une pratique stricte de Test-Driven Development.
  • Les seuls tests unitaires qui ont du sens sont ceux qui ciblent le point d’entrée du use case car elles amènent la connaissance du contexte ! (dans notre exemple Race#start).
    Un test unitaire est orienté F-O-N-C-T-I-O-N-N-E-L dans 98% des cas.
    Coupler un test à des détails de d’implémentations (des stratégies d’algorithmes par exemple) freineraient tes possibilités de refactoring car vite cassés au moindre changement important d’implémentation.
  • Moins tu utilises de doublures (comme les mocks), moins tes tests seront « connaisseurs » de tes détails d’implémentations et donc meilleures seront tes capacités en futur refactoring.
    Pour comprendre ce qu’est un mock et saisir ce en quoi il freine le refactoring, lisez cet article.
  • Il peut arriver que si tu suis scrupuleusement le principe d’inversions de dépendances, certains tests ne compilent plus, car ne prenant pas encore en compte une nouvelle dépendance (rôle) qui vient d’émerger; mais ce n’est pas grave et c’est normal !
    Il te suffit d’ajouter le MINIMUM de code dans le test pour injecter la nouvelle dépendance sans jamais être trop couplée à elle lors de la définition du test (exemple, je ne vérifie pas son nombre d’appels ni vérifie qu’une ou plusieurs de ses méthodes aient été invoquées !).
    Imagine-là comme une boîte noire; de façon à ce que tes tests soient les plus solides et pérennes possibles !
  • Tu vas prendre goût aux tests car avec cette façon de faire, tu seras beaucoup plus productif !
    Moins de tests éparpillés sans contexte intégré, et plus de robustesse !
  • Et si tu as l’impression que tes tests unitaires ressembleront à TA définition de tests d’intégration, c’est bon signe !
    Ça porte même un nom, on parle d‘Integrated tests, pour nuancer avec les Integration tests.
    Un test d’intégration a pour vocation de vérifier les collaborations avec les éléments externes de l’application (ORM etc), de les « intégrer », sans impliquer une seule règle de gestion métier !
    Alors qu’un test unitaire quant à lui spécifie TOUTES les règles de gestion de l’application, sans jamais être couplé aux collaborateurs externes.
    Pour plus de détails sur cette nuance, voir la vidéo de Ian Cooper en lien plus bas.

Si t’as aimé cet article, n’hésite pas à le partager autour de toi de manière à briser cette mauvaise idée reçue explicitant qu’un test unitaire = test sur une méthode d’une classe et seulement d’une classe; cela n’a pas de sens, vraiment 😉

Et si tu veux davantage d’informations à ce sujet, voici un autre super article de Ian Cooper ainsi que sa vidéo sur le sujet. 

Published inTesting

Be First to Comment

Laisser un commentaire

Votre adresse de messagerie ne sera pas publiée. Les champs obligatoires sont indiqués avec *