Classification des tests.
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 :
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 ».
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é.
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.
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 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.
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.
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.
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 codice_1, son test unitaire est la classe codice_2. De même, supposons maintenant qu'il existe une classe codice_3 (son test est codice_4) qui dépend de codice_1, 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 :
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.
Pour illustrer la nécessité d'isoler les tests entre eux, prenons l'exemple du test d'un petit service codice_6 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 codice_7, le développeur crée un utilisateur en base et dans codice_8, il tente de le retrouver. Cette classe de test est mauvaise : le développeur suppose, pour le bon déroulement de codice_8 que la méthode codice_7 sera appelée avant. Cela est une mauvaise supposition pour trois raisons :
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.
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.
Exhaustivité.
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.
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.
On distingue notamment :
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 codice_18 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 codice_19) sur deux n'est pas testée.
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 (codice_20 et ET (codice_21 :
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.
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.
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.
À titre d'exemple, vous pouvez parcourir le . 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.
Qualité des tests par analyse de mutations.
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 ».
Une classe mutante est une copie d'une classe originale dans laquelle on introduit une petite erreur. Parmi ces erreurs, on peut :
Principe de l'analyse de mutation.
Pour une classe C donnée et sa classe de test T :
Un bon test doit éliminer tous les mutants.
Limites.
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é.
Qualité du code.
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…