Principes et conseils : Maintenir des suites de tests automatisés
Rubriques
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. Il ne compilera même pas. Etant donné que peu de travail utile peut être fait sans connexion, peu de tests nécessitant une connexion peuvent être écrits.
Vous pouvez faire face à des centaines ou milliers de tests échoués.
Ces tests peuvent être corrigés à l'aide d'un outil de recherche et remplacement global 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 est 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 chose est une perte de temps, notamment quand, et c'est souvent le cas, les tests ne sont pas facilement modifialbes. Il existe une meilleure solution.
Supposez que les tests n'utilisaient pas à l'origine la méthode de 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 ressembler à cela :
public boolean testLogin (String username) {
return product.login(username);
}
Quand le changement de la version 2.0 intervient, la bibliothèque utilitaire change pour s'adapter :
public Boolean testLogin (String username) {
return product.login(username, "dummy password");
}
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. Dans la pratique, il n'est pas possible de tous les prévoir; vous pouvez ne pas vous rendre compte que vous avez besoin d'une méthode utilitaire testConnexion jusqu'à ce que la méthode de connexion du produit change pour la première fois.
Ainsi, 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 continueront de l'appeler directement. Si son comportement change, certain ou tous ces tests devront être mis à jour. (Si aucun des tests de connexion n'échoue quand son comportement change, ils sont probablement mauvais pour détecter 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 mise en place peut et doit être
exprimée sous forme abstraite en une seule méthode avec un nom évocateur tel que ComptePrêtPourVirement.
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 defauts. 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 ressembler à cela :
/*
* Given
* while (i<0) { f(a+i); i++;}
* "a+i" cannot be hoisted from the loop because
* it contains a variable changed in the loop.
*/
loopTest = new LessOp(new Token("i"), new Token("0"));
aPlusI = new PlusOp(new Token("a"), new Token("i"));
statement1 = new Statement(new Funcall(new Token("f"), aPlusI));
statement2 = new Statement(new PostIncr(new Token("i"));
loop = new While(loopTest, new Block(statement1, statement2));
expect(false, loop.canHoist(aPlusI))
Il s'agit d'un test difficile à lire. Supposez sur le temps passe. Quelque chose change qui vous oblige à mettre les tests à jour. Vous avez alors à faire l'organigramme 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("while (i<0) { f(a+i); i++; }");
// Get a pointer to the "a+i" part of the loop.
aPlusI = loop.body.statements[0].args[0];
expect(false, 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 sur la façon dont les arbres syntaxiques sont formés. Il est toujours sensible à la structure
d'un arbre syntaxique de bouche while (en raison des
séquences d'accès requises pour extraire le sous-arbre pour a+i).
Si cette structure s'avère invariable, le test peut être rendu plus abstrait en créant une méthode d'utilitaire extraireSousarbre :
loop=Parser.parse("while (i<0) { f(a+i); i++; }");
aPlusI = fetchSubtree(loop, "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 ++),
que 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 chance de trouver un défaut causé par le changement ; c'est une opération que vous faites 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 se débarasser tout simplement des tests et de consacre 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 se débarassent de 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 similaire). 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 testera é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).
|