Comme n'importe quel objet, les tests peuvent se casser. Non pas à cause de l'usure mais en raison d'un changement dans
leur environnement. Peut-être ont-ils été portés sur un nouveau système d'exploitation. Ou bien, plus probablement, le
code qu'ils exercent a changé d'une façon qui provoque correctement l'échec du test. Supposez que vous
travaillez sur la version 2.0 d'une application de banque en ligne. Dans la version 1.0, la méthode suivante servait à
la connexion :
connexion booléenne publique (Chaîne nom d'utilisateur) ;
Dans la version 2.0, le service marketing s'est rendu compte qu'une protection par un mot de passe pourrait être une
bonne idée. La méthode a donc été remplacée par la suivante :
connexion booléenne publique (Chaîne nom d'utilisateur, Chaîne mot de passe) ;
Tout test impliquant une connexion échouera. La compilation ne se fera même pas. Etant donné que peu de travail utile
peut être effectué à ce stade, il existe peu de tests utiles à écrire qui n'utilisent pas de connexion. Vous pouvez
vous retrouver avec des centaines ou des milliers d'échecs de tests.
Ces tests peuvent être réparés en utilisant un outil Rechercher/Remplacer général, qui recherche toutes les instances
de connexion (quelque chose) et les remplace par connexion (quelque chose, "mot de passe factice").
Ensuite, faites en sorte que tous les comptes de test utilisent ce mot de passe, et le problème sera résolu.
Par la suite, quand le service marketing décide que les mots de passe ne doivent pas contenir d'espaces, vous devez
répéter l'opération.
Ce genre de problèmes représente une perte de temps, notamment quand, et c'est souvent le cas, les tests ne sont pas
facilement modifiables. Il existe une meilleure solution.
Supposez que ces tests n'aient pas appelé la méthode connexion du produit. Ils utilisaient à la
place une méthode de bibliothèque qui s'occupe de tout pour la connexion du test et la suite de celui-ci. Au début,
cette méthode peut se présenter comme suit :
Connexion test booléen public (nom d'utilisateur alphanumérique) {
retour produit.connexion (nom d'utilisateur);
}
Quand le changement de la version 2.0 intervient, la bibliothèque utilitaire change pour s'adapter :
Connexion test booléen public (nom d'utilisateur alphanumérique) {
retour produit.connexion (nom d'utilisateur
, "mot de passe factice");
}
Au lieu de changer un millier de tests, vous ne changez qu'une méthode.
Dans l'idéal, toutes les méthodes de bibliothèque nécessaires doivent être disponibles au début de l'effort de test. En
pratique, ils ne peuvent pas être tous anticipés - vous pouvez ne pas vous rendre compte que vous avez besoin d'une
méthode utilitaire de Connexion test avant que la connexion du produit
change pour la première fois. Les méthodes utilitaires de test "s'inspirent" souvent de tests existants au besoin. Il
est très important que vous effectuiez cette réparation permanente des tests, même lorsque le calendrier est
serré. Dans le cas contraire, vous perdrez beaucoup de temps avec une suite de tests médiocres et impossibles à
entretenir. Vous pourriez avoir à vous en débarrasser, ou être incapable d'écrire le nombre nécessaire de nouveaux
tests parce que tout votre temps disponible pour les tests serait consacré à l'entretien des anciens.
Remarque : les tests de la méthode de connexion du produit l'appelleront toujours
directement. Si son comportement change, une partie ou l'intégralité de ces tests devront être mis à jour. (Si aucun
des tests de connexion n'échoue lorsque son comportement change, ils détectent probablement mal
les défauts.)
L'exemple précédent a montré comment des tests peuvent rendre une application concrète plus abstraite. Vous pouvez très
probablement recourir à davantage d'abstraction. Vous pouvez trouver qu'un certain nombre de tests commencent par une
séquence commune d'appel de méthodes : ils se connectent, définissent certains états et naviguent dans la partie de
l'application que vous testez. Ce n'est qu'alors que chaque test fait quelque chose de différent. Toute cette
installation peut et doit être contenue dans une seule méthode avec un nom évocateur tel que comptePrêtPourTransfertBancaire. Ce faisant, vous économisez un temps considérable quand de nouveaux
tests d'un type particulier sont écrits, et vous rendez ainsi l'objet de chaque test beaucoup plus compréhensible.
Il est important que les tests soient compréhensibles. Il arrive souvent avec les anciennes suites de tests que
personne ne sache ce qu'ils font et à quoi ils servent. Quand ils se cassent, ils sont le plus souvent corrigés le plus
simplement possible. Cela a pour conséquence de rendre les tests moins performants dans la détection des défauts. Ils
ne testent plus ce pour quoi ils étaient conçus au départ.
Imaginez que vous testez un compilateur. Certaines des premières classes écrites définissent l'arbre syntaxique interne
du compilateur et les transformations qui lui ont été apportées. Vous disposez d'un certain nombre de tests qui
construisent des arbres syntaxiques et testent les transformations. Un tel test peut se présenter comme suit :
/*
* Etant donné
* alors que (i<0) { f(a+i); i++;}
* "a+i" ne peut pas être extrait de la boucle car
* il contient une variable modifiée dans la boucle.
*/
Test de boucle = nouveau LessOp(nouvelle marque("i"), nouvelle marque("0"));
aPlusI = nouveau PlusOp(nouvelle marque("a"), nouvelle marque("i"));
instruction1 = nouvelle instruction(nouveau Funcall(nouvelle marque("f"), aPlusI));
instruction2 = nouvelle instruction(nouveau PostIncr(nouvelle marque("i"));
boucle = nouveau While(test boucle, nouveau bloc(instruction1, instruction2));
prévision(faux, loop.canHoist(aPlusI))
Il s'agit d'un test difficile à lire. Supposez qu'un peu de temps s'écoule. Un changement vous oblige à mettre les
tests à jour. Vous avez alors à dessiner l'architecture d'une infrastructure produit plus grande. Vous pouvez notamment
disposer d'un sous-programme d'analyse syntaxique qui transforme les chaînes en arbres syntaxiques. Il serait alors
préférable à ce niveau de réécrire les tests complètement pour l'utiliser :
loop=Parser.parse("alors que (i<0) { f(a+i); i++; }");
// Mettez un pointeur sur la partie "a+i" de la boucle.
aPlusI = loop.body.statements[0].args[0];
prévision (faux, loop.canHoist(aPlusI));
De tels tests seront bien plus simples à comprendre, ce qui vous fera gagner du temps dans l'immédiat et à l'avenir. En
fait, leurs coûts de maintenance sont tellement inférieurs qu'il peut valoir la peine d'en différer la plupart jusqu'à
ce que l'analyseur syntaxique soit disponible.
Cette approche présente un léger désavantage : de tels tests peuvent découvrir un défaut soit dans le code de
transformation (comme prévu) soit dans l'analyseur syntaxique (par accident). L'isolation du problème et le débogage
peuvent ainsi être d'une certaine façon plus difficiles. D'autre part, trouver un problème que les tests de l'analyseur
syntaxique ne voient pas n'est pas si grave.
Il y a aussi une possibilité qu'un défaut dans l'analyseur syntaxique puisse masquer un défaut dans le code de
transformation. Cette possibilité est très faible, et son coût est presque certainement inférieur au coût de
maintenance de tests plus compliqués.
Une grande suite de tests contiendra des blocs de tests qui ne changent pas. Ils correspondent aux zones stables dans
l'application. D'autres blocs de tests changeront souvent. Ils correspondent aux zones de l'application où le
comportement change souvent. Ces derniers blocs de tests auront tendance à alourdir l'utilisation des bibliothèques
d'utilitaires. Chaque test testera des comportements spécifiques dans la zone variable. Les bibliothèques d'utilitaires
sont conçues pour permettre à un tel test de vérifier ses comportements ciblés tout en restant relativement à l'abri
des changements dans les comportements non testés.
Par exemple, le test "extraction de boucle" illustré ci-dessus est maintenant immunisé contre les détails relatifs à la
manière dont les arbres syntaxiques sont formés. Il demeure encore sensible à la structure d'un while
(alors que) d'un arbre syntaxique de boucle (en raison des séquences d'accès requises pour extraire le
sous-arbre pour a+i). Si cette structure s'avère modifiable, le test peut être rendu plus abstrait en créant une
méthode utilitaire extraire sous-arbre :
boucle=Parser.parse("alors que (i<0) { f(a+i); i++; }");
aPlusI = extraire sous-arbre(boucle, "a+i");
expect(false, loop.canHoist(aPlusI));
Le test est maintenant sensible à seulement deux choses : la définition du langage (par exemple, que les nombres
entiers puissent être augmentés avec ++), et les règles régissant l'extraction de boucle (le
comportement dont il vérifie la correction).
Même avec les bibliothèques d'utilitaires, un test peut parfois être cassé par des changements de comportement qui
n'ont rien à voir avec ce qu'il vérifie. Corriger le test ne donne que peu de chances de trouver un défaut causé par le
changement ; c'est une opération que vous effectuez pour préserver la possibilité du test de trouver un autre défaut un
jour. Mais le coût d'une telle série de corrections peut dépasser la valeur des éventuelles détections de défauts du
test. Il peut être plus valable de supprimer tout simplement des tests et de consacrer vos efforts à la création de
nouveaux tests plus avantageux.
La plupart des personnes résistent à la notion de suppression des tests, du moins jusqu'à ce qu'elles soient tellement
surchargées par la maintenance qu'elles suppriment tous les tests. Il est préférable de prendre la décision avec
précaution et de façon continue, test par test, en se demandant :
-
Quel travail représente la correction de ce test, et son éventuel ajout à la bibliothèque d'utilitaires ?
-
Comment ce temps pourrait être utilisé autrement ?
-
Quelle est la probabilité que le test trouve d'importants défauts à l'avenir ? Quels résultats a-t-il obtenus par
le passé, ainsi que les tests connexes ?
-
Combien de temps s'écoulera avant que le test ne se casse à nouveau ?
Les réponses à ces questions seront de vagues estimations, voire des suppositions. Mais y répondre apportera de
meilleurs résultats que d'avoir simplement comme principe de corriger tous les tests.
Une autre raison de supprimer des tests est lorsqu'ils sont redondants. Par exemple, au début du développement, il peut
y avoir une multitude de tests simples de méthodes de base de construction d'arbres syntaxiques (le constructeur LessOp et autres). Ensuite, pendant l'écriture de l'analyseur syntaxique, il y aura un certain nombre
de tests d'analyseur syntaxique. Etant donné que l'analyseur syntaxique utilise les méthodes de construction, les tests
de l'analyseur syntaxique les testeront également indirectement. Alors que des changements de code cassent les tests de
construction, il est raisonnable d'en supprimer certains s'ils sont redondants. Bien sûr, tout comportement de
construction nouveau ou modifié nécessitera de nouveaux tests. Ils peuvent être mis en oeuvre directement (s'ils sont
difficiles à tester à fond à travers l'analyseur syntaxique) ou indirectement (si des tests à travers l'analyseur
syntaxique sont adéquats et plus faciles à entretenir).
|