Introduction au test logiciel/Qualité des tests

Un livre de Wikilivres.

Classification des tests[modifier | modifier le wikicode]

Un des concepts fondamentaux du développement logiciel est la séparation des préoccupations. C'est un principe à respecter également lorsqu'on écrit des tests : un test doit répondre à une préoccupation précise. Aussi, il est impératif lors de l'écriture d'un test de savoir :

  • quel est le périmètre du (sous-)système testé,
  • à quel niveau d'abstraction se place-t-on. Si l'application est divisée en couches (architecture n-tiers), avec l'API de quelle couche le test est écrit,
  • quelles sont les suppositions (on ne re-teste pas toute l'application à chaque fois). Si on teste une couche donnée, la plupart du temps, on suppose que les couches inférieures fonctionnent (elle-mêmes seront testées dans d'autres tests).

Toutes ces informations doivent être précisées dans la documentation du test ou de la classe de test. Pour vous aider à faire le tri, la classification ci-dessous définit quelques critères qui peuvent aider à caractériser un test.

Tests « boîtes blanches » et tests « boîtes noires »[modifier | modifier le wikicode]

On peut parler de tests « boîtes blanches » ou de tests « boîtes noires » selon la visibilité que le développeur qui écrit les tests a sur le code testé.

Test boîte blanche
Le testeur, pour écrire son test, tient compte de la structure interne du code testé et de l'implémentation. Avec la connaissance de l'implémentation, le testeur peut facilement visualiser les différentes branches et les cas qui provoquent des erreurs. Éventuellement, le test peut faire appel à des méthodes protégées voire privées.
Test boîte noire
Le testeur ne tient pas compte de l'implémentation mais ne s'appuie que sur la partie publique de l'élément testé pour écrire les tests.

Avec les tests boîte blanche, il est plus facile d'atteindre une bonne couverture étant donné que l'accès au code testé permet de voir les différentes branches et les cas d'erreurs. L'avantage des tests en boite noire est, qu'une fois écrits, l'implémentation de la classe testée peut changer sans nécessiter la mise à jour du test. Ainsi, en Java, un test boîte noire écrit en faisant appel aux méthodes d'une interface peut permettre de tester toutes les classes qui réalisent cette interface.

Tests fonctionnels et tests non-fonctionnels[modifier | modifier le wikicode]

On parle de tests fonctionnels quand il s'agit de vérifier qu'une classe permet bien de remplir avec succès l'objectif fixé par un cas d'utilisation[1] donné. Un test fonctionnel permet de répondre à la question « est-ce que le code permet de faire ça ? » ou « est-ce que cette fonctionnalité attendue est bien fonctionnelle ? ».

Par opposition, les tests non-fonctionnels vérifient des propriétés qui ne sont pas directement liées à une utilisation du code. Il s'agit de vérifier des caractéristiques telle que la sécurité ou la capacité à monter en charge. Les tests non-fonctionnels permettent plutôt de répondre à des questions telles que « est-ce que cette classe peut être utilisée par 1000 threads en même temps sans erreur ? ».

Tests unitaires, tests d'intégration et tests systèmes[modifier | modifier le wikicode]

Tandis qu'un test unitaire vise à tester une unité isolée pendant le test, un test d'intégration est un test qui met en œuvre plusieurs unités ou composants pour vérifier qu'ils fonctionnent bien ensemble. Le test système peut être considéré comme un test d'intégration global : il vérifie le fonctionnement de la totalité du système assemblé, lorsque tous les composants qui interviendront sont en place.

Isolation[modifier | modifier le wikicode]

Derrière la notion d'isolation, on distingue deux exigences. La première consiste à assurer l'isolation des composants testés, la seconde à assurer l'isolation des tests entre eux.

Isolation des composants testés[modifier | modifier le wikicode]

Il est important pour un test de ne tester qu'une seule chose, c'est à dire d'assurer le caractère unitaire du test. Supposons qu'il existe une classe A, son test unitaire est la classe ATest. De même, supposons maintenant qu'il existe une classe B (son test est BTest) qui dépend de A, c'est à dire qu'elle manipule des instances de A (appel de méthode) ou hérite de A. Si tous les tests passent, tout va bien.

Maintenant, supposez que le test de B ne passe pas. Il y a plusieurs hypothèses :

  • il y a un bogue dans B ;
  • il y a un bogue dans A, et cela provoque un comportement inattendu dans B.

Le problème auquel on est confronté est simple : Où le bogue se cache-t-il ? Faut-il chercher le bogue dans A ou dans B ? Pour être fixé, il faut repasser le test de A, s'il passe, c'est que le problème est dans B, sinon il est peut-être dans A ou ailleurs… En effet, nous avons réduit notre système à deux classes, mais que se passerait-il si A dépendait encore de plusieurs autres classes du système. On peut ainsi en introduisant un bogue, avoir des dizaines de tests qui ne passent plus dans diverses parties du système.

C'est typiquement un problème d'isolation. En théorie, un test unitaire ne doit tester qu'une unité, c'est à dire que s'il y avait un bogue dans A, seul le test de A devrait ne plus passer pour nous permettre de localiser précisément l'origine du problème. C'est dans le test de B que se situe le problème d'isolation : en effet, son succès dépend de la qualité de l'implémentation de A, alors que c'est B qu'on doit tester et pas autre chose. Le test de B ne devrait pas manipuler A mais utiliser des doublures de A, ceci afin de garantir l'isolation. En fait, notre test de B n'est pas un test unitaire mais plutôt un test d'intégration (qui vérifie que A et B interagissent bien).

Finalement, dans un système intégralement testé unitairement, chaque classe a son test, et chacun de ces tests ne manipule que la classe testée en isolation, en utilisant des doublures pour toutes les autres classes. Ainsi, si un test échoue, on sait exactement quelle est la classe où se trouve l'erreur de programmation.

Bien que séduisante, cette approche s'avère inapplicable. En effet, écrire autant de doublures pour tous ces tests devient contre-productif et s'avère pénible à la maintenance (chaque fois qu'un contrat change, il faut modifier toutes les doublures dans tous les tests des classes dépendantes). À vous de trouver le compromis entre deux extrêmes : une étanchéité complète entre les tests (beaucoup de code, temps de maintenance) et tests sans isolation (difficulté pour localiser l'origine du problème).

Isolation des tests entre eux[modifier | modifier le wikicode]

Pour illustrer la nécessité d'isoler les tests entre eux, prenons l'exemple du test d'un petit service ServiceUser proposant les opérations courantes (CRUD) pour gérer une base d'utilisateurs en permettant de les authentifier par mot de passe.

public class ServiceUserTest {

    /** Vérifie qu'on peut créer un utilisateur puis l'authentifier. */
    @Test
    public void testCreateConnectUser() {
        // on crée un utilisateur et on l'enregistre
        User user = new User();
        user.setLogin("toto");
        user.setPassword("mdp");
        serviceUser.createUser(user);

        // on doit pouvoir authentifier un utilisateur qui a été ajouté
        boolean authUser = serviceUser.connectUser("toto", "mdp");
        Assert.assertTrue(authUser);
    }

    /** Vérifie qu'on peut récupérer un utilisateur enregistré en base. */
    @Test
    public void testFindUser() {
        // on doit pouvoir retrouver un utilisateur enregistré
        // à partir de son login
        User user = serviceUser.findUserByLogin("toto");
        Assert.assertNotNull(user);
    }
}

Dans testCreateConnectUser(), le développeur crée un utilisateur en base et dans testFindUser(), il tente de le retrouver. Cette classe de test est mauvaise : le développeur suppose, pour le bon déroulement de testFindUser() que la méthode testCreateConnectUser() sera appelée avant. Cela est une mauvaise supposition pour trois raisons :

  • D'abord, un développeur qui veut lancer seulement la méthode de test testFindUser() peut très bien le faire. JUnit, les IDE et les outils de build le permettent, cela fait partie du paradigme xUnit. Dans ce cas, la méthode testCreateConnectUser() n'est pas exécutée et l'utilisateur attendu n'a pas été ajouté en base donc le test échoue même si la classe ServiceUser est bien implémentée.
  • Même si le développeur demande au test-runner de lancer tous les tests de la classe, rien ne garantie que les méthodes seront exécutées dans l'ordre dans lequel elles apparaissent dans le code source. Le test-runner peut très bien appeler testCreateConnectUser() après testFindUser(). Dans ce cas, testFindUser() échoue pour les mêmes raisons qu'au point précédent alors qu'il n'y a pas de bogue dans ServiceUser.
  • Si le premier test se déroule mal, ce test termine et laisse le service dans un état inconnu, inattendu. Le second test peut alors échouer ou réussir aléatoirement, ce qui peut laisser supposer qu'il y a un bogue dans findUserByLogin() alors que ce n'est pas le cas.

Lorsque vous écrivez une méthode de test, vous devez toujours ignorer ce qui est fait dans les autres méthodes de test et ne jamais supposer qu'elles seront appelées avant ou après celle que vous écrivez.

Déterminisme[modifier | modifier le wikicode]

Un test n'est utile que s'il est reproductible. En effet, si un test détecte une erreur et qu'on a fait une correction, si on ne peut pas rejouer le test dans les mêmes conditions que celles qui ont produit l'échec, on ne pourra pas savoir si la correction est bonne ou si c'est un coup de chance.

C'est pourquoi il est nécessaire de fournir systématiquement en entrée du système testé le même ensemble de données. Sont donc à proscrire les données aléatoires (générées), les données issues de l'environnement hôte (date courante, locale, adresse réseau locale…). Pour assurer le déterminisme, il convient d'isoler le composant qui fournit ces données dans l'application pour en créer une doublure. Cette doublure fournira, selon le besoin pour les tests, une date courante fixée dans les tests, des nombres aléatoires générés à partir d'un générateur initialisé avec une graine fixe (fixer la graine du générateur rend déterministe la suite de nombre générée etc.). Les données de test générées peuvent également être enregistrées dans un fichier qui sera lu pour fournir les mêmes données à chaque exécution du test.

Il n'est pas convenable de laisser des tests non-déterministe dans la suite de tests. En effet, s'ils échouent, on ne peut vérifier si c'est à cause de l'indéterminisme ou si c'est l'insertion d'un bogue qui a provoqué l'échec[2].

Exhaustivité[modifier | modifier le wikicode]

La qualité de tests reposent évidemment sur leur exhaustivité, c'est-à-dire, si l'application est testée dans les différentes situations possibles de départ, les différentes utilisations qu'on peut faire du système, que le système fonctionne dans les cas nominaux et dans les cas d'erreurs (lorsqu'une ou plusieurs erreurs surviennent).

Lorsqu'on a une base de test conséquente, il devient difficile de distinguer ce qui est testé de ce qui ne l'est pas. Heureusement, des outils existent pour analyser notre suite de test et nous indiquer leur exhaustivité. L'approche la plus fréquemment retenue est le calcul de la couverture du code par les tests.

Couverture du code par les tests[modifier | modifier le wikicode]

La couverture du code (ou code coverage) permet d'évaluer la qualité d'un jeu de test en vérifiant quelles sont les parties du code qui sont appelées lors des tests.

Si du code est appelé pendant un test, cette portion de code est considéré comme couverte ; a contrario, tout le code non-appelé est considéré comme non-couvert. La couverture s'exprime donc sous forme d'un pourcentage représentant la proportion de code couverte sur la quantité totale de code.

Différentes façons de mesurer la couverture[modifier | modifier le wikicode]

On distingue notamment :

Couverture des méthodes (function coverage ou method coverage)
qui vérifie que chaque méthode (publique, protégée ou privée) d'une classe a été exécutée.
Couverture des instructions (statement coverage ou line coverage)
qui vérifie que chaque instruction a été exécutée.
Couverture des chemins d'exécution (branch coverage)
qui vérifie que chaque parcours possible (par exemple, les 2 cas passant et non-passant d'une instruction conditionnelle) a été exécuté.

Cette différence est significative, prenons l'exemple suivant :

public void uneMethode(boolean test) {
    if (test) {
        instruction1();
    } else {
        instruction2();
        instruction3();
        instruction4();
        instruction5();
        instruction6();
        instruction7();
        instruction8();
        instruction9();
    }
}

Si votre test appelle cette méthode en passant false en paramètre, vous avez assuré la couverture en lignes de 90 % du code puisque vous avez exécuté 9 instructions sur les 10 présentes dans le corps de la méthode. Toutefois, vous n'assurez que 50 % de la couverture en branche puisque une branche (le cas test=true) sur deux n'est pas testée.

Couverture d'efficacité des opérandes booléens (boolean operand effectiveness ou MC/DC : Modified Condition/Decision Change)
Ce type de couverture fait partie des types de couvertures pour le niveau le plus strict (niveau A) de la norme DO-178B utilisée en avionique et les secteurs où des systèmes critiques sont développés. La couverture consiste à couvrir plus en détails les expressions conditionnelles en mesurant l'efficacité de chacun des opérandes et permet la détection d'opérandes non effectif (jamais évalué, ou inutile dans l'expression car dépendant d'un autre).
Ce type de test est donc utilisé avec des expressions booléennes complexes dans les instructions de contrôle d'exécution (condition, boucle, ...). Les expressions booléennes sont en général évaluées de manière incomplète :
a OU b
si a est vrai, b n'a pas besoin d'être évalué car le résultat sera vrai quel que soit la valeur de b.
a ET b
si a est faux, b n'a pas besoin d'être évalué car le résultat sera faux quel que soit la valeur de b.

Pour comprendre ce type de couverture, voici un exemple d'expression conditionnelle pour tester si une année est bissextile ou non :

/* dans une fonction prenant un paramètre : annee  */
...
if ( (annee % 4 == 0) && ( (annee % 100 != 0) || (annee % 400 == 0) )
{
    ...

Chacun des opérandes doit être évalué à vrai et à faux sans que les autres opérandes ne changent, tout en changeant le résultat de la condition. L'expression contient 3 opérandes reliés par les opérateurs booléens OU (||) et ET (&&) :

  • (annee % 4 == 0)
  • (annee % 100 != 0)
  • (annee % 400 == 0)

Les 4 tests suivants permettent une couverture d'efficacité des opérandes booléens de 100 % : 1900, 1980, 1983, 2000. La mesure d'efficacité est résumée dans un tableau pour chacun des opérandes. Le point d'interrogation (?) signifie que l'on peut considérer que ? == true ou ? == false, car l'opérande n'est pas évalué pour des raisons de rapidité d'exécution du programme qui a déjà obtenu le résultat de la condition en début de ligne.

Efficacité de (annee % 4 == 0)
Test annee = (annee % 4 == 0) && ( (annee % 100 != 0) || (annee % 400 == 0) ) Résultat
1983 false ? ? false
1980 true true ? true
Constat Changement Constant Constant Changement


Efficacité de (annee % 100 != 0)
Test annee = (annee % 4 == 0) && ( (annee % 100 != 0) || (annee % 400 == 0) ) Résultat
1900 true false false false
1980 true true ? true
Constat Constant Changement Constant Changement


Efficacité de (annee % 400 == 0)
Test annee = (annee % 4 == 0) && ( (annee % 100 != 0) || (annee % 400 == 0) ) Résultat
1900 true false false false
2000 true false true true
Constat Constant Constant Changement Changement

Déterminer les cas de test pour cette couverture est plus complexe lorsque les opérandes sont des appels de fonctions ou méthodes.

Une métrique non-linéaire[modifier | modifier le wikicode]

S'il est facile, en écrivant des tests d'atteindre 50 % de couverture, il est plus difficile d'atteindre 70 à 80 %. Plus la couverture est grande, plus il est difficile de l'augmenter.

Selon les organisations et l'exigence de qualité, il peut être obligatoire d'avoir un pourcentage minimal de couverture. Voir, par exemple, la norme DO-178B (en avionique) qui exige, à partir de la criticité d'un composant la couverture en ligne, la couverture en branches ou les deux à 100 % (tout écart devant être justifié dans un document).

Quelques outils pour évaluer les couvertures[modifier | modifier le wikicode]

À titre d'exemple, vous pouvez parcourir le « rapport de couverture généré par Cobertura sur le framework Web Tapestry » (ArchiveWikiwixQue faire ?). On peut y lire, pour chaque package et chaque classe la couverture en lignes et en branches ainsi qu'une mesure de la complexité du code. De plus, pour chaque classe, on peut voir, dans le détail, les lignes couvertes (surlignées en vert) et les lignes non-couvertes (surlignées en rouge). Autre exemple, le rapport de couverture généré par Emma sur la base de donnée H2.

Pour Java
Cobertura (couvertures par lignes et par branches), Emma (Eclemma permet d'intégrer Emma à Eclipse pour distinguer les lignes non-couvertes directement dans l'IDE).
Pour JavaScript
Script-cover, une extension pour le navigateur Google Chrome.
Pour PHP
Xdebug
Pour Python
PyPi coverage, voir aussi cette conférence
Pour Ruby
Simplecov

Qualité des tests par analyse de mutations[modifier | modifier le wikicode]

L'analyse de mutation (ou mutation testing) permet d'évaluer la qualité d'un test en vérifiant sa capacité a détecter les erreurs que le développeur aurait pu introduire.

Notion de « mutant »[modifier | modifier le wikicode]

Une classe mutante est une copie d'une classe originale dans laquelle on introduit une petite erreur. Parmi ces erreurs, on peut :

  • supprimer une instruction ;
  • inverser l'ordre de deux instructions ;
  • remplacer tout ou partie d'une expression booléenne par true ou false ;
  • remplacer des opérateurs (par exemple, remplacer == par != ou <= par >) ;
  • remplacer une variable par une autre.

Principe de l'analyse de mutation[modifier | modifier le wikicode]

Pour une classe C donnée et sa classe de test T :

  1. On génère tous les mutants ;
  2. Pour chaque mutant C', on fait passer le test T ;
    • si le T échoue, le mutant est éliminé ;
    • si le T passe, le mutant est conservé.

Un bon test doit éliminer tous les mutants.

Limites[modifier | modifier le wikicode]

Le principal défaut de ce système est qu'il est probable qu'un mutant soit équivalent à la classe originale (le code diffère syntaxiquement, mais la sémantique du code est identique). Comme il est équivalent, il n'est pas éliminé et provoque un faux-positif. Cependant, le test est bon même si le mutant n'est pas éliminé.

Outils[modifier | modifier le wikicode]

Java
PIT

Qualité du code[modifier | modifier le wikicode]

Au même titre que le code de l'application elle-même, le code des tests doit être compréhensible par tous et maintenu sur le long terme. Il doit donc être développé avec la même rigueur que du code habituel : respect des conventions, éviter le code copié-collé (factoriser le code commun dans une classe mère, une classe utilitaire ou une classe externe), utilisation parcimonieuse des patrons de conception et de l'héritage, commentaires, documentation, etc.

L'effort doit être mis sur la simplicité et l'évidence de code des tests. Dès lors qu'on a un doute sur l'exactitude d'un test (on se demande, lorsque le test passe, si c'est parce que le code est bon ou le test trop faible), la démarche de test devient absurde. L'extrême limite est franchie lorsque qu'un test est tellement compliqué qu'il devient nécessaire de tester le test…

Références[modifier | modifier le wikicode]

  1. il peut aussi s'agir d'une user story
  2. À ce sujet, voir l'article « Eradicating Non-Determinism in Tests », par Martin Fowler