Voici un exemple de code incorrect :
File file = new File(stringName);
file.delete();
Le problème est que File.delete peut échouer mais que le code ne fait pas de vérification pour
cette erreur. La solution consiste à ajouter le code en italique montré ici :
File file = new File(stringName);
if (file.delete()
== false) {...}
Ces instructions décrivent une méthode de détection de situations dans lesquelles le code ne gère pas le résultat de
l'appel d'une méthode. Elles supposent que la méthode appelée entraîne un résultat correct, quelle que soit l'entrée
donnée. Ce point doit faire l'objet d'un test, mais la création d'idées de test pour la méthode appelée est une tâche à
part. En d'autres termes, vous n'avez pas à vous charger de tester File.delete.)
La notion clé est que vous devez créer une idée de test pour chaque résultat pertinent non géré de l'appel de la
méthode. Pour définir ce terme, commençons par le résultat. Lors de son exécution, une méthode change l'état de
la réalité. Exemples :
-
Elle peut placer des valeurs de retour sur la pile d'exécution.
-
Elle peut lancer une exception.
-
Elle peut modifier une variable globale.
-
Elle peut mettre à jour un enregistrement dans une base de données.
-
Elle peut envoyer des données sur le réseau.
-
Elle peut imprimer un message dans la sortie standard.
Observons maintenant l'aspect pertinent du résultat, là encore à l'aide de quelques exemples.
-
Imaginez que la méthode appelée appelle un message dans la sortie standard. Cette action change l'état de la
réalité mais n'affecte pas le traitement à venir de ce programme. Quel que soit le contenu imprimé et même si
l'impression est vide, l'exécution du code ne peut être affectée.
-
Si la méthode renvoie true pour indiquer qu'elle a abouti et false en cas d'échec, il est fort probable que votre
programme tienne compte du résultat. Cette valeur de retour est donc pertinente.
-
Si la méthode appelée met à jour un enregistrement de base de données que votre code lit et utilise par la suite,
le résultat (mise à jour) est pertinent.
Il n'existe pas de limite absolue entre pertinent et non pertinent. En appelant print, votre
méthode peut entraîner l'allocation éventuellement pertinente des mémoires tampons. Il est possible qu'un incident
dépende de l'opération d'allocation et des mémoires tampons allouées. Possible certes, mais est-ce pour autant
plausible ?
Une méthode peut souvent avoir un très grand nombre de résultats, dont certains seulement sont différents.
Imaginez par exemple une méthode écrivant des octets sur le disque. Elle peut renvoyer un nombre inférieur à zéro pour
signaler un échec ; sinon, elle retourne le nombre d'octets écrits, qui peut être inférieur au nombre demandé. Les
diverses possibilités peuvent être rassemblées dans trois résultats distincts :
-
le nombre est inférieur à zéro,
-
le nombre écrit est égal à celui demandé,
-
des octets ont été écrits, mais moins que le nombre demandé.
Toutes les valeurs inférieures à zéro sont regroupées dans un résultat, car aucun programme ne fera la différence entre
elles. Toutes les valeurs (dans le cas où, justement, il est possible d'en avoir plusieurs) doivent être traitées comme
des erreurs. De la même façon, si le code demandait l'écriture de 500 octets, peu importe si seuls 34 ou 340 ont
vraiment été écrits ; il en sera peut-être de même avec les octets non écrits. Si une valeur doit être traitée
différemment (0, par exemple), elle entraîne un nouveau résultat.
Il reste un dernier point pour la définition. Cette technique de test ne s'intéresse pas aux différents résultats déjà
gérés. Prenez le code suivant :
File file = new File(stringName);
if (file.delete() == false) {...}
Il existe deux résultats distincts (true et false) que le code gère. La gestion peut être incorrecte, mais les idées de
test issues de Instructions pour le produit : Idées de test pour des expressions booléennes et des
conditions frontière le vérifieront. Cette technique de test s'intéresse aux différents résultats qui ne sont pas
précisément gérés par du code distinct. Cette situation peut se produire pour deux raisons : vous avez pensé que la
différence n'était pas importante ou elle vous a échappé. Voici un exemple du premier cas de figure :
result = m.method();
switch (result) {
case FAIL:
case CRASH:
...
break;
case DEFER:
...
break;
default:
...
break;
}
FAIL CRASH sont gérés par le même code. Il peut être prudent de vérifier
que ce principe est approprié. Voici maintenant un exemple du second cas de figure :
result = s.shutdown();
if (result == PANIC) {
...
} else {
// Réussite. Eteindre le réacteur.
...
}
Il s'avère que l'arrêt peut renvoyer un résultat distinct : RETRY. Le code tel qu'écrit traite
cette situation comme dans un cas de réussite, ce qui a de fortes chances d'être incorrect.
Votre objectif est de considérer les résultats importants qui vous ont échappé auparavant, ce qui semble impossible :
pourquoi penseriez-vous maintenant qu'ils sont importants si vous ne l'avez pas fait plus tôt ?
Réponse : une révision systématique de votre code, avec une mentalité de test au lieu d'une approche de programmation,
peut parfois vous offrir de nouveaux points de vue. Vous pouvez remettre en cause vos propres hypothèses en
parcourant votre code de façon méthodique, en observant les méthodes appelées, en repassant leur documentation et en
réfléchissant. Voici quelques situations à rechercher.
Cas "impossibles"
Il s'avère souvent que les codes erreur sont impossibles. Vérifiez à nouveau vos hypothèses.
Cet exemple montre l'implémentation Java d'un schéma de mise en oeuvre Unix pour gérer des fichiers temporaires.
File file = new File("tempfile");
FileOutputStream s;
try {
// ouvrir le fichier temp.
s = new FileOutputStream(file);
} catch (IOException e) {...}
// vérifier que le fichier temp sera supprimé
file.delete();
L'objectif est de vérifier qu'un fichier temporaire est toujours supprimé, quelle que soit la façon dont le programme
est quitté. Pour ce faire, créez le fichier temporaire et supprimez-le immédiatement. Sous Unix, vous pouvez poursuivre
votre travail avec le fichier supprimé et le système d'exploitation se charge du nettoyage au terme du processus. Un
programmeur Unix recherchant la facilité n'écrira peut-être pas de code pour vérifier un échec de suppression. De la
même façon que la création du fichier a abouti, sa suppression doit aussi réussir.
Cette astuce ne fonctionne pourtant pas dans Windows. La suppression échoue en effet car le fichier est ouvert.
Identifier ce motif n'est pas simple : jusqu'à août 2000, la documentation Java ne mentionnait pas les situations dans
lesquelles delete pouvait échouer et se contentait de signaler qu'un échec était possible.
Cependant, dans un contexte de test, le programmeur peut mettre en cause cette hypothèse. Comme son code est censé être
écrit une fois pour s'exécuter partout, il doit demander à un programmeur Windows dans quels cas File.delete échoue sous Windows et donc découvrir la triste réalité.
Cas "non pertinents"
Un autre facteur empêchant de remarquer une valeur pertinente est d'être déjà convaincu qu'elle est sans importance.
Une méthode Java comparedes outils de comparaison renvoie un nombre
<0, 0, ou >0. Il s'agit de trois cas distincts à essayer. Ce code réunit deux d'entre eux :
void allCheck(Comparator c) {
...
if (c.compare(o1, o2) <= 0) {
...
} else {
...
}
Ce code peut toutefois s'avérer incorrect. Pour découvrir si tel est le cas, essayez les deux situations séparément,
même si vous êtes convaincu que le résultat sera identique. Vos convictions sont en fait ce que vous testez. Vous
pouvez exécuter le cas then de l'instruction if à plusieurs reprises pour
d'autres raisons. Pourquoi ne pas essayer une fois avec le résultat inférieur à 0 et une autre avec un résultat nul ?
Exceptions non interceptées
Les exceptions sont une sorte de résultat distinct. Prenez le code qui suit comme point de départ :
void process(Reader r) {
...
try {
...
int c = r.read();
...
} catch (IOException e) {
...
}
}
Vous êtes censé vérifier si le code gestionnaire agit comme il se doit en cas d'échec de lecture. Imaginez cependant
qu'une exception est explicitement non gérée. A la place, elle est autorisée à se propager à travers le code testé.
Dans Java, le résultat donnerait ce qui suit :
void process(Reader r)
throws IOException {
...
int c = r.read();
...
}
Cette technique vous demande de tester ce cas même si le code ne le gère pas de façon explicite. Pourquoi cela ?
La raison est ce type de faute :
void process(Reader r) throws IOException {
...
Tracker.hold(this);
...
int c = r.read();
...
Tracker.release(this);
...
}
Ici, le code affecte l'état général (via Tracker.hold). Si l'exception est lancée, Tracker.release n'est jamais appelé.
L'échec de publication n'aura certainement aucune conséquence immédiate. Le problème ne sera probablement pas visible
tant que le processus n'est pas appelé à nouveau, d'où le nouvel échec de mise
en attente de l'objet. L'article de Keith Stobie ("Testing for Exceptions") apporte des informations utiles sur ces incidents (en anglais).
(Télécharger Adobe Reader))
Cette technique particulière ne traite pas tous les incidents associés aux appels de méthodes. Deux types ont toutes
les chances d'y échapper.
Arguments incorrects
Prenez les deux lignes de code C suivantes, la première étant incorrecte et la seconde correcte.
... strncmp(s1, s2, strlen(s1)) ...
... strncmp(s1, s2, strlen(
s2)) ...
strncmp compare deux chaînes et renvoie un nombre inférieur à 0 si la première est antérieure à
la seconde sur le plan lexicographique (son entrée arrive avant dans un dictionnaire). Il renvoie la valeur 0 si elles
sont égales. Le nombre retourné est en revanche supérieur à 0 si la première chaîne est plus grande sur le plan
lexicographique. Toutefois, la comparaison porte uniquement sur le nombre de caractères indiqué par le troisième
argument. Le problème est que la longueur de la première chaîne sert à limiter la comparaison, alors qu'il devrait en
fait s'agir de celle de la seconde chaîne.
Cette technique demande trois tests, un pour chaque valeur de retour. En voici trois possibles :
s1
|
s2
|
résultat attendu
|
résultat obtenu
|
"a"
|
"bbb"
|
<0
|
<0
|
"bbb"
|
"a"
|
>0
|
>0
|
"foo"
|
"foo"
|
=0
|
=0
|
L'incident n'est pas identifié car rien dans cette technique n'oblige le troisième argument à posséder une
valeur déterminée. Un jeu d'essai comme suit est alors nécessaire :
s1
|
s2
|
résultat attendu
|
résultat obtenu
|
"foo"
|
"food"
|
<0
|
=0
|
Même si certaines techniques se prêtent à la détection de ce genre d'incidents, elles sont dans la pratique rarement
appliquées. Votre effort de test sera probablement plus profitable si vous le consacrez à un ensemble de tests visant
de nombreux types d'incidents et pouvant éventuellement identifier cet incident comme un effet secondaire.
Résultats confus
Il existe un danger lié au fait de coder et de tester méthode par méthode. Voici un exemple avec deux méthodes. La
première, connect, tente d'établir une connexion réseau :
void connect() {
...
Integer portNumber = serverPortFromUser();
if (portNumber == null) {
// message contextuel sur le numéro de port invalide
return;
}
Lorsqu'elle a besoin d'un numéro de port, elle appelle serverPortFromUser . Cette méthode renvoie alors
deux valeurs distinctes. Elle retourne un numéro de port choisi par l'utilisateur s'il est valide (1000 ou supérieur).
Sinon, elle renvoie une valeur null. Dans ce cas, le code testé émet un message d'erreur et prend fin.
Lorsque la méthode connect est testée, elle fonctionne comme prévue : un numéro de port valide
permet d'établir une connexion et un numéro incorrect entraîne un message d'erreur.
Le code pour serverPortFromUser est légèrement plus compliqué. Il ouvre d'abord une fenêtre
demandant une chaîne et comportant les boutons OK et Annuler standard. En fonction de l'intervention de l'utilisateur,
quatre scénarios sont possibles :
-
Si l'utilisateur entre un nombre valide, ce nombre est renvoyé.
-
Si le nombre est trop petit (inférieur à 1000), une valeur null est renvoyée et le message sur le numéro de port
incorrect affiché.
-
Si le format du nombre est incorrect, la valeur null est à nouveau renvoyée et le même message s'affiche également.
-
Si l'utilisateur clique sur Annuler, la valeur null est renvoyée.
Ce code fonctionne aussi comme prévu.
La combinaison des deux extraits de code a cependant une conséquence négative : l'utilisateur clique sur Annuler et
obtient un message au sujet d'un numéro de port erroné. L'ensemble du code fonctionne comme prévu mais l'effet global
est toujours incorrect. Il a été testé de façon appropriée, sauf qu'un incident n'a pas été identifié.
Le problème ici est que la valeur null est l'un des résultats possédant deux
significations (valeur incorrecte ou annulé par l'utilisateur). Rien dans cette technique ne vous fait remarquer
ce défaut de conception de serverPortFromUser.
Un test peut cependant aider. Lorsque vous testez serverPortFromUser de façon isolée pour voir
si la valeur renvoyée est celle prévue dans chacun de ces quatre cas, le contexte d'utilisation est perdu. Imaginez à
la place un test avec la méthode connect. Dans ce cas, quatre tests appliqueraient les deux
méthodes à la fois :
entrée
|
résultat attendu
|
processus imaginé
|
l'utilisateur entre "1000"
|
la connexion au port 1000 est établie
|
serverPortFromUser renvoie un nombre qui est utilisé
|
l'utilisateur entre "999"
|
message d'erreur à propos d'un numéro de port incorrect
|
serverPortFromUser retourne une valeur null qui affiche un message
|
l'utilisateur entre "i99"
|
message d'erreur à propos d'un numéro de port incorrect
|
serverPortFromUser retourne une valeur null qui affiche un message
|
l'utilisateur clique sur Annuler
|
tout le processus de connexion doit être annulé
|
serverPortFromUser renvoie une valeur null, ce qui n'a pas de sens
|
Comme c'est souvent le cas, le test dans un contexte plus large dévoile des problèmes d'intégration échappant aux tests
de portée plus réduite. Comme bien souvent aussi, une réflexion poussée lors de la conception du test permet
d'anticiper le problème avant d'exécuter le test. Si l'incident n'est toutefois pas détecté avant, il le sera lors du
test.
|