Introduction au test logiciel/Doublures de test

Nous avons vu que les tests unitaires pouvaient rapidement devenir difficile à écrire pour diverses raisons. Nous allons voir que dans de nombreux cas, nous pouvons résoudre ces problèmes par l'utilisation de doublures. Il s'agit d'objets qui seront substitués aux objets attendus pour obtenir un comportement qui rend les tests plus facile à écrire.

Pourquoi utiliser une doublure

modifier

Parmi les problèmes qu'on peut rencontrer lors de l'écriture de tests, nous pouvons énumérer :

  1. La classe testée fait appel à un composant difficile ou coûteux à mettre en place. Typiquement : une base de données. Si on veut tester cette classe, il faut, au début du test, créer une base de données, insérer des données pertinentes (ce qui peut être très compliqué si la base contrôle l'intégrité référentielle et que le schéma est complexe ou si des déclencheurs sont appelés), et, à la fin du test, les supprimer. On risque en plus, si le test est interrompu (erreur, levée d'exceptions) de ne pas supprimer les données et d'avoir une base dans un état inconnu.
  2. Le test vise à vérifier le comportement d'une classe dans une situation exceptionnelle. Par exemple, on veut vérifier le comportement d'un code réseau lorsqu'une déconnexion survient. Il est évidemment absurde d'imaginer un développeur qui attend le bon moment de son test pour débrancher le câble réseau de sa machine... Typiquement, il s'agit de vérifier la réaction d'un code lors de la levée d'une exception.
  3. La classe testée fait appel à un composant qui n'est pas encore disponible de façon stable. C'est souvent le cas lorsqu'une application est découpée en couches et que les différents développeurs développent ces couches en parallèle. Il se peut alors qu'un composant ne soit tout simplement pas disponible ou que son interface soit soumise à des changements très fréquents. Imaginons une application Web simple, 3-tiers. On a un premier composant, l'interface utilisateur qui fait appel à un second composant, le code métier qui fait appel à un troisième composant chargé de la persistance (via une base de données). Comment tester le code métier quand le composant persistance est toujours en cours de développement ? Comment tester l'interface graphique si le code métier n'est toujours pas stable ?
  4. Le test fait appel à du code lent. Il est souhaitable d'avoir un jeu de test permettant de s'assurer de la qualité globale du système qui ne prenne pas des heures. Si la classe testée fait appel à autre classe lente (parce que les calculs sont très complexes, parce qu'il y des accès disques ou tout autre raison). Comment tester cette classe seulement sans être ralenti par la lenteur de la seconde ?
  5. La classe testée fait appel à du code non-déterministe. Typiquement, la classe se comporte différemment selon l'heure courante, ou des nombres générés aléatoirement. On n'imagine pas non plus un développeur changer l'horloge système de son système au cours de ses tests ou attendre la nuit pour lancer ces tests afin de vérifier si la classe se comporte bien pendant la nuit.
  6. Il semble nécessaire d'ajouter du code à une classe pour mener des tests. Une application est, en général, déjà suffisamment compliquée sans qu'on ait besoin de rajouter du code dont certains se demanderont à quoi il sert puisqu'il n'est jamais appelé dans le reste de l'application. Il faut éviter que du code spécifique aux tests parasite le code d'une classe applicative.

Dans chacun de ces cas, nous allons voir que l'utilisation de doublures peut résoudre le problème posé.

Présentation des doublures

modifier
 
Des mannequins (« dummy » en anglais) sont utilisés dans les essais de choc automobile pour vérifier que les passagers sont bien protégés en cas d'accident

S'il fallait faire une analogie pour expliquer les doublures, on pourrait parler des mannequins utilisés lors des essais de choc automobile. Ils jouent le rôle de doublures d'êtres humains pour vérifier le comportement de la voiture dans une situation exceptionnelle : l'accident (on retrouve le cas n°2). Les doublures reproduisent la structure et la forme des originaux (les mannequins ont le poids, la morphologie de véritables être humains pour que les tests soient fiables) mais la voiture ne s'en rend pas compte, elle réagit comme si elle transportait des humains.

Quand on parle de test logiciel, une doublure est un objet qui va, au moment de l'exécution, remplacer un objet manipulé par du code. Le code manipulateur ne s'en rend pas compte, une bonne doublure trompe le code appelant qui doit agir comme s'il manipulait un objet normal. Écrire des doublures ne requiert aucune compétence technique particulière autre qu'écrire du code dans le langage de l'application testée ou d'apprendre à utiliser de petites bibliothèques. En revanche, la création de doublures efficaces fait appel à la ruse du développeur. Il faut maîtriser le langage et savoir jouer avec le polymorphisme et les petites subtilités de chaque langage pour réaliser les doublures les plus simples possibles.

Comme nous allons le voir, il existe plusieurs types de doublures.

Les bouchons (ou « stubs »)

modifier

Un bouchon est le type le plus simple de doublure, il s'agit d'une classe, écrite à la main. Son implémentation tient compte du contexte exact pour lequel on a besoin d'une doublure. Quand on écrit un bouchon on considère tout le système comme une boîte blanche, on sait comment il est implémenté on en tient compte pour écrire le bouchon le plus simple possible, avec le minimum de code nécessaire pour qu'il puisse remplir son rôle de doublure.

Un exemple

modifier

Dans l'exemple suivant, nous allons tomber dans le cas n°3. Dans cet exemple, nous sommes chargés de développer la partie messagerie utilisateur d'un logiciel, c'est un autre développeur qui se charge de la partie authentification utilisateur du système. Son travail est de fournir au logiciel une implémentation de l'interface IdentificationUtilisateur que vous devrez utiliser dans votre messagerie.

/** permet de vérifier qu'une personne à bien accès au système */
public interface IdentificationUtilisateur {

    /** vérifie qu'un utilisateur existe et que son mot de passe est le bon
     * @param identifiant l'identifiant de la personne à authentifier
     * @param motDePasse le mot de passe de la personne
     * @return vrai si l'utilisateur existe et que le mot de passe est le bon
     */
    boolean identifier(String identifiant, String motDePasse);
}

Vous avez fait votre travail et avez développé une classe MessagerieUtilisateur qui gère la messagerie, elle utilise IdentificationUtilisateur pour vérifier les identifiants et mot de passe de l'utilisateur.

public class MessagerieUtilisateur {

    protected IdentificationUtilisateur identification;

    public MessagerieUtilisateur(IdentificationUtilisateur identification) {
        this.identification = identification;
    }
    
    public List<Message> lireMessages(String identifiant,
                                      String motDePasse)
                                      throws IdentificationException {

        boolean identification = identification.identifier(identifiant, motDePasse);
        if (identification) {
            // code qui retourne la liste des messages pour cet utilisateur
        } else {
            throw new IdentificationException();
        }        
    }
}

Soucieux de la qualité de votre implémentation, vous vous lancez dans la création d'un test unitaire sous JUnit. Il devrait, au moins en partie, ressembler à la classe suivante :

/** Test de la classe MessagerieUtilisateur */
public class MessagerieUtilisateurTest {

    @Test
    public void testLireMessages() {

        // problème : comment instancier messagerie, il nous manque un paramètre
        MessagerieUtilisateur messagerie = new MessagerieUtilisateur(???);

        try {
            messagerie.lireMessages("toto", "mdp");
        } catch (IdentificationException e) {
            fail();
        }

        try {
            messagerie.lireMessages("toto", "mauvais_mot_de_passe");
            // une exception aurait due être levée
            fail();
        } catch (IdentificationException e) {
            assertTrue(true);
        }
    }
}

Nous voici devant le problème, le développeur chargé d'implémenter l'interface IdentificationUtilisateur dont vous avez besoin ne peut pas encore vous la fournir, il a d'autres tâches prioritaires. Vous allez devoir vous en passer. C'est à ce moment que vous allez créer un bouchon. Cette classe va se substituer à celle qui manque le temps du test.

/** doublure de la classe IdentificationUtilisateur
 *  c'est un bouchon qui est utilisé dans MessagerieUtilisateurTest
 */
public class IdentificationUtilisateurStub implements IdentificationUtilisateur {

    /** seul le compte "toto" avec le mot de passe "mdp" peut s'identifier */
    @Override
    boolean identifier(String identifiant, String motDePasse) {
        if ("toto".equals(identifiant) && "mdp".equals(motDePasse) {
            return true;
        }
        return false;
    }
}

Il ne reste plus qu'à écrire dans le test ligne 7

MessagerieUtilisateur messagerie = new MessagerieUtilisateur(new IdentificationUtilisateurStub());

Vous pouvez maintenant dérouler le test, sans plus attendre. Le bouchon a permis de résoudre le problème de dépendance non-satisfaite envers un composant qui n'est pas disponible.

Les simulacres (ou « mock objects »)

modifier

Contrairement aux bouchons, les simulacres ne sont pas écrits par le développeur mais générés par l'usage d'un outil dédié, qui permet de générer un simulacre à partir de la classe originale. Le simulacre généré est d'un type similaire à la classe originale et peut donc la remplacer. On peut ensuite, par programmation, définir comment la doublure doit réagir quand elle est manipulée par la classe testée.

Java
Il en existe plusieurs mais le plus utilisé est Mockito. D'autres solutions sont plus abouties comme Mockachino ou JMockit ;
C++
Google propose le Google C++ Mocking Framework ;
Groovy
Gmock ;
Ruby
mocha
JavaScript
SinonJS
Dans les autres langages
vous pouvez lire l'article « List of mock object frameworks » de Wikipedia anglophone.

Introduction à Mockito

modifier
  Dépendance Maven (?)

<dependency>
  <groupId>org.mockito</groupId>
  <artifactId>mockito-core</artifactId>
  <scope>test</scope>
</dependency>

  Dépendance Maven (?)

<dependency>
  <groupId>org.mockito</groupId>
  <artifactId>mockito-all</artifactId>
  <scope>test</scope>
</dependency>

Mockito est une bibliothèque Java qui permet de créer des simulacres.

Reprenons l'exemple précédent. Cette fois, grâce à Mockito, nous n'aurons plus besoin d'écrire le bouchon que nous avons utilisé précédemment. Nous n'allons pas utiliser un bouchon mais un simulacre, créé juste au moment du test.

import static org.mockito.Mockito.*; // permet d'utiliser mock() et when()

/** Test de la classe MessagerieUtilisateur avec un simulacre */
public class MessagerieUtilisateurTest {
    @Test
    public void testLireMessages() {

        // mockIdentification sera notre simulacre, comme on peut le voir
        // c'est bien un objet qui implémente l'interface IdentificationUtilisateur
        IdentificationUtilisateur mockIdentification;

        // la méthode statique mock de la class Mockito permet de créer
        // le simulacre.
        mockIdentification = mock(IdentificationUtilisateur.class);

        // maintenant, il faut spécifier le comportement attendu par notre simulacre
        // pour que le test se déroule bien
        when(mockIdentification.lireMessages("toto", "mdp")).thenReturn(true);
        when(mockIdentification.lireMessages("toto", "mauvais_mot_de_passe")).thenReturn(false);


        // notre simulacre est prêt à être utilisé
        MessagerieUtilisateur messagerie = new MessagerieUtilisateur(mockIdentification);

        try {
            messagerie.lireMessages("toto", "mdp");
        } catch (IdentificationException e) {
            fail();
        }

        try {
            messagerie.lireMessages("toto", "mauvais_mot_de_passe");
            // une exception aurait due être levée
            fail();
        } catch (IdentificationException e) {
            assertTrue(true);
        }
    }
}

L'outil de génération de simulacres nous a permis d'éviter d'écrire une nouvelle classe (le bouchon). Cela nous a permis d'écrire le test plus vite sans ajouter de code excessif.

Limites de Mockito

modifier

Mockito, en interne, joue avec la réflexion et l'héritage de Java. Par conséquent, on ne peut pas mocker

  • les méthodes equals() et hashCode(), Mockito les utilise en interne et les redéfinir pourrait dérouter Mockito ;
  • les méthodes privées (private) ;
  • les classes déclarées final ;
  • les méthodes déclarées final (Mockito exécutera le code original de la classe sans tenir compte de vos instructions sans vous en informer donc soyez vigilant) ;
  • les méthodes de classes (static).

Les trois dernières impossibilités sont dues à la façon dont Java charge les classes en mémoire. Il est possible de remédier à ces limitations justement en changeant la façon dont Java charge les classes. C'est ce que propose PowerMock qui étend Mockito.

Les espions (ou « spy »)

modifier

Un espion est une doublure qui enregistre les traitements qui lui sont fait. Les tests se déroulent alors ainsi :

  1. On introduit une doublure dans la classe testée
  2. On lance le traitement testé
  3. Une fois finie, on inspecte la doublure pour savoir ce qu'il s'est passé pour elle. On peut vérifier qu'une méthode a bien été appelée, avec quels paramètres et combien de fois etc.

L'intérêt de cette méthode est manifeste lorsqu'il s'agit de tester des méthodes privées. On ne peut vérifier la valeur retournée ou que les paramètres passés successivement au travers des différentes méthodes sont bons. Dans ce cas, on laisse l'espion passer successivement pour toutes les méthodes privées et c'est seulement à la fin que l'espion nous rapporte ce qu'il s'est vraiment passé.

Une classe espionne peut être codée à la main mais la plupart des outils de création de simulacres évoqués plus haut créent des doublures qui sont aussi des espions.

Un exemple, avec Mockito

modifier

Les doublures créées par Mockito sont aussi des espions. Réécrivons notre test en utilisant cette possibilité.

/** Test de la classe MessagerieUtilisateur avec un espion */
public class MessagerieUtilisateurTest {
 
    public void testLireMessages() throws Exception {
 
        // mockIdentification sera notre simulacre, comme on peut le voir
        // c'est bien un objet qui implémente l'interface IdentificationUtilisateur
        IdentificationUtilisateur mockIdentification;
 
        // la méthode statique mock de la class Mockito permet de créer
        // le simulacre.
        mockIdentification = Mockito.mock(IdentificationUtilisateur.class);
 
        // maintenant, il faut spécifier le comportement attendu par notre simulacre
        // pour que le test se déroule bien
        Mockito.when(mockIdentification.identifier("toto", "mdp")).thenReturn(true);
        Mockito.when(mockIdentification.identifier("toto", "mauvais_mot_de_passe")).thenReturn(false);
 
 
        // notre simulacre est prêt à être utilisé
        // étape 1 : on introduit la doublure
        MessagerieUtilisateur messagerie = new MessagerieUtilisateur(mockIdentification);
 
        // étape 2 : on lance le traitement
        messagerie.lireMessages("toto", "mdp");
        messagerie.lireMessages("toto", "mauvais_mot_de_passe");

        // étape 3 : Le code a été exécuté, voyons ce que rapporte l'espion
        // vérifions que la méthode identifier() a bien été appelée exactement une fois avec ces paramètres
        Mockito.verify(mockIdentification, times(1)).identifier("toto", "mdp");
        // et exactement une fois avec ces paramètres
        Mockito.verify(mockIdentification, times(1)).identifier("toto", "mauvais_mot_de_passe");
    }
}

Mockito propose bien sûr d'autres utilisation de verify(), permet de vérifier l'ordre dans lequel les méthodes ont été appelées etc. Pour voir toutes les possibilités, il faut se référer à la documentation de Mockito.

Les substituts (ou « fake »)

modifier

Un substitut est une doublure écrite à la main qui implémente le comportement attendu d'une classe mais de façon plus simple. Contrairement au bouchon qui est écrit spécifiquement pour un test, le substitut a vocation à être suffisamment générique pour être utilisé dans plusieurs tests. Il est donc plus compliqué à écrire que le bouchon mais à l'avantage d'être réutilisable, le temps supplémentaire passé à écrire cette doublure peut être rentabilisé si elle est réutilisée dans d'autres tests.

Un exemple

modifier

Revenons à notre premier exemple, celui du bouchon. Notre test est très court, supposons que nous avons d'autres tests à mener sur notre classe MessagerieUtilisateur. Par exemple, on voudrait tester le comportement de la classe avec plusieurs utilisateurs différents, et vérifier que les messages sont bien stockés. Nous avons la possibilités de créer d'autres bouchons pour chacun de ces tests, mais essayons plutôt d'écrire un substitut qui pourra servir dans chaque test.

/** doublure de la classe IdentificationUtilisateur
 *  c'est un substitut qui est utilisé dans MessagerieUtilisateurTest
 */
public class IdentificationUtilisateurStub implements IdentificationUtilisateur {

    /** les comptes de tous les utilisateurs, identifiants et mot de passes associés */
    Map<String, String> comptes = new HashMap<String, String>();
 
    /** vrai si le compte a été ajouté et le mot de passe est valide */
    @Override
    boolean identifier(String identifiant, String motDePasse) {
        boolean result = false;
        if (comptes.containsKey(identifiant)) {
            if (comptes.get(identifiant).equals(motDePasse)) {
                result = true;
            }
        }
        return result;
    }

    /** ajoute un compte à l'ensemble des comptes valides */
    public void ajouterCompte(String identifiant, String motDePasse) {
        comptes.put(identifiant, motDePasse);
    }
}

Ce substitut devrait nous permettre d'écrire les différents tests évoqués. Il suffira, au début du test, d'ajouter zéro, un ou plusieurs comptes selon le besoin.

Les fantômes (ou « dummy »)

modifier

Il s'agit du type de doublure le plus simple, il s'agit simplement d'un objet qui ne fait rien puisqu'on sait que, de toute façon, dans la méthode testée, il n'est pas utilisé. Il y a plusieurs façons simples de créer un fantôme :

  • Il peut s'agir d'un objet qui implémente une interface et laisse toutes les implémentations vides[1] ;
  • Un simulacre généré ;
  • Un objet quelconque, par exemple une instance de Object en Java (on peut très bien faire Object fantome = new Object();) ;
  • Parfois, l'absence de valeur convient très bien (null).

Comment utiliser les doublures

modifier

Finalement, reprenons la totalité des cas évoqués et voyons comment les doublures peuvent nous aider.

  1. La classe testée fait appel à un composant difficile ou coûteux à mettre en place. Il faut une doublure pour ce composant. Ce composant doit avoir une façade, en général, pour tester une partie. Si on reprend l'exemple de la base de données, un bouchon, qui renvoie des données minimum écrites en dur juste pour le test (comme dans l'exemple de bouchon) devrait faire l'affaire. Comme c'est en général un gros composant, la façade peut être très grande (beaucoup de méthodes à implémenter) mais il y a de fortes chances que le test ne fasse vraiment appel qu'à une petite partie de celles-ci. Ainsi, il ne faut pas hésiter à créer un semi-fantôme, c'est à dire une doublure qui fait bouchon pour les quelques méthodes utilisées et qui en fait ne fait rien pour tout le reste. S'il y a beaucoup de tests, et que le composant n'est pas une façade trop développée, peut-être qu'un substitut du composant évitera la création de beaucoup de bouchons.
  2. Le test vise à vérifier le comportement d'une classe dans une situation exceptionnelle. On peut très bien remplacer l'implémentation d'une classe par une doublure qui remplace l'implémentation d'une méthode par une levée d'exception.
  3. La classe testée fait appel à un composant qui n'est pas encore disponible de façon stable. Utiliser une doublure à la place du composant le temps d'écrire les tests et de les passer, remplacer la doublure par le composant final quand celui-ci est prêt (et garder la doublure dans un coin, ça peut toujours servir).
  4. Le test fait appel à du code lent. S'il s'agit d'une partie isolée, on peut en faire un substitut plus rapide. S'il s'agit d'une application réseau (beaucoup d'entrées/sorties), il faut plutôt envisager de faire un substitut bas niveau seulement pour la couche réseau, et de passer les messages d'objets à objets (censés être distants mais en fait tout deux en mémoire dans le même processus) directement sans passer par le réseau. S'il s'agit d'algorithmes complexes, qui prennent beaucoup de temps, utiliser une doublure qui renvoie des données codées en dur, voire utiliser un algorithme plus efficace pour faire une estimation et renvoyer des données du même ordre de grandeur que celles attendues.
  5. La classe testée fait appel à du code non-déterministe. Faire une doublure qui renvoie des données de façon déterministe. Pour l'horloge, diriger vers plusieurs doublures qui renvoient chacune une heure donnée, codée en dur. Pour le générateur de nombres aléatoires, une doublure qui renvoie les éléments connus d'une liste, voire toujours le même nombre. Une autre façon est d'utiliser un générateur qui permet de fixer une base de départ (une graine). Lorsque deux générateurs aléatoires sont créé avec la même graine, ils génèrent les mêmes données aléatoires. Ce comportement déterministe permet de prédire le comportement des classes testées et d'écrire les tests adéquats.
  6. Il semble nécessaire d'ajouter du code à une classe pour mener des tests. À priori, ce code qui doit être ajouté permet en général de faire des vérifications sur ce qui a été produit. Plutôt que de modifier cette classe, il doit pouvoir être possible de générer un simulacre et de l'utiliser comme espion. Sinon, créer une nouvelle sous-classe dans les tests pour ajouter les comportements manquants.

Quoiqu'il en soit, il faut toujours essayer d'utiliser les doublures avec parcimonie. Elles donnent l'illusion de tester l'application alors qu'en fait, on teste des coquilles vides qui ne permettent finalement pas de présager du comportement final de l'application, quand celle-ci utilisera intégralement les implémentations en lieu et place des doublures. Abandonner l'idée d'utiliser une doublure si son code devient trop complexe par rapport à la complexité du test qu'elle est censée permettre.

Tester une classe abstraite

modifier

L'objectif est de tester les méthodes concrètes d'une classe non-instantiables. Supposons l'existence d'une classe A, abstraite, et de B et C des implémentations concrètes. Plusieurs possibilités :

  • écrire un test abstrait pour A dont les tests de B et C hériteront pour tester également les méthodes héritées. Dans ce cas, le même test (si les méthodes concrètes de A ne sont pas redéfinies) de chaque méthode de A sera rejoué pour chaque sous-classe de A, ce qui est une perte de temps ;
  • écrire un test concret de A. Dans ce cas, il faut dans ce test créer une implémentation de A ;
    • créer un bouchon pour A : dans ce cas, à chaque ajout d'une méthode abstraite dans A, il faudra compléter la doublure qui peut avoir de nombreuses implémentations vides, ce qui est déplaisant ;
    • créer un simulacre de A et simplement lui dire de renvoyer les vrais résultats lors de l'appel.

Mockito permet cela grâce à son support de mock réels partiels (c'est à dire des simulacres qui peuvent adopter le vrai comportement de la classe simulée) :

    @Test
    public testUneMethodeConcrete() {
        // préparation des données en entrées pour le test
        int a = 1;

        A mock = Mockito.mock(A.class);
        Mockito.when(mock.uneMethodeConcrete(a)).thenCallRealMethod();

        // assertions....
        assertEquals(2, mock.uneMethodeConcrete(a));
    }

Implications de l'utilisation de doublures

modifier
  • Bénéfices apportés par les doublures
    • Découplage entre les classes
    • Reproductibilité accrue
    • Augmentation significative de la qualité des tests étant donné la possibilité de tester les situations exceptionnelles
  • Inconvénients des doublures
    • Temps de développement initial
    • Temps de maintenance : nécessité pour les doublures de suivre les changements dans les classes originales
    • Lorsqu'une classe fonctionne avec une doublure, cela ne garantit pas que la classe fonctionnera quand il s'agira d'interagir avec la véritable implémentation. Pour vérifier cette seconde propriété, il faudra recourir à un test d'intégration.

Limites des doublures

modifier

Dans certains codes, il sera difficile de substituer un objet par sa doublure. C'est particulièrement le cas si, par exemple, on souhaite remplacer un objet qui est un attribut privé de la classe, qu'il est affecté dès l'initialisation de la classe par la classe elle-même et qu'on a pas de possibilité de le modifier depuis l'extérieur.

/* comment remplacer attributPrive par sa doublure pour tester
 * methode() ? Dans ce cas, ce n'est pas possible.
 */
public class ClasseTestee {

    private final UneClasseQuelconque attributPrive = new UneClasseQuelconque();

    public methode() {
        // code qui fait appel a attributPrive
    }
}

La manière dont cette classe est écrite limite l'utilisation de doublures, elle réduit la testabilité du code et montre que les possibilités d'utilisation de doublures peuvent être réduites voire nulles. Nous verrons dans le chapitre « Tests et conception » comment éviter ce genre d'écueil.

Références

modifier
  1. Voir aussi le patron de conception Null Object