Instructions: Maintenir des suites de tests automatisés
Ces instructions présentent les principes de conception et de gestion qui facilitent la maintenance des suites de tests.
Relations
Description principale

Introduction

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'abstraction aide à gérer la complexité

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.

Un autre exemple

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.

Se concentrer sur l'amélioration des tests

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).

Suppression de tests

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 :

  1. Quel travail représente la correction de ce test, et son éventuel ajout à la bibliothèque d'utilitaires ?
  2. Comment ce temps pourrait être utilisé autrement ?
  3. 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 ?
  4. 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).