Introduction
L'expression "test développeur" sert à classer par catégorie les tâches de test effectuées de préférence par les
développeurs du logiciel. Elle englobe aussi les produits créés par ces tâches. Les tests développeur comprennent le
travail traditionnellement considéré dans les catégories suivantes : tests d'unité, une grande partie des tests
d'intégration et certains aspects de ce que l'on appelle souvent tests système. Bien qu'ils soient traditionnellement
associés aux tâches de la discipline Implémentation, ils sont également liés aux tâches de la discipline Analyse et
Conception.
En envisageant les tests développeur de cette façon "holistique", vous contribuez à atténuer certains risques associés
à l'approche plus "atomiste" généralement adoptée. Dans l'approche traditionnelle des tests développeur, l'effort se
concentre au départ sur l'assurance que toutes les unités fonctionnent de façon indépendante. Plus tard dans le cycle
de vie de développement, alors que le travail de développement tire à sa fin, les unités intégrées sont assemblées dans
un sous-système de travail et testées pour la première fois dans cette configuration.
Cette approche présente un certain nombre de défauts. Tout d'abord, étant donné qu'elle encourage une approche par
étapes des tests des unités intégrées, puis des sous-systèmes, les erreurs identifiées pendant ces tests sont souvent
trouvées trop tard. Leur découverte tardive entraîne généralement la décision de ne prendre aucune mesure corrective,
ou bien la correction de ces erreurs exige un travail supplémentaire considérable. Ce travail supplémentaire coûte cher
et empêche d'avancer dans d'autres domaines. Cela augmente le risque que le projet déraille ou soit abandonné.
Deuxièmement, créer de frontières rigides entre les tests d'unité, d'intégration et système augmente la probabilité que
personne ne découvre les erreurs traversant ces frontières. Ce risque est accru lorsque la responsabilité de ces types
de tests est attribuée à des équipes distinctes.
Le style de test développeur recommandé par RUP encourage le développeur à se concentrer sur les tests les plus
importants et appropriés à réaliser à un moment donné. Même dans la portée d'une itération unique, il est généralement
plus efficace pour le développeur de trouver et corriger autant d'anomalies que possible dans son propre code, sans
engendrer les coûts indirects supplémentaires qu'implique le transfert à un groupe de tests séparé. Le résultat
escompté est la découverte anticipée des erreurs logicielles les plus significatives, qu'elles soient dans l'unité
indépendante, l'intégration des unités ou le fonctionnement des unités intégrées dans un scénario d'utilisateur final
significatif.
Pièges à éviter lors de la mise en route des tests
développeur
Beaucoup de développeurs qui essaient de faire un travail de test considérablement plus approfondi abandonnent souvent
peu de temps après avoir commencé. Ils estiment que cela ne semble rien apporter. En outre, certains développeurs qui
commencent bien les tests développeur trouvent qu'ils ont créé une suite de tests impossibles à maintenir qu'ils
finissent par abandonner.
Cette page vous fournit quelques conseils pour franchir les premiers obstacles et pour créer une suite de tests évitant
le piège de maintenabilité. Pour plus d'informations, voir Instructions : Maintenance des suites de tests automatiques.
Etablissement des attentes
Ceux qui trouvent les tests développeur gratifiants les exécutent. Ceux qui les considèrent comme une corvée trouvent
le moyen d'y échapper. Cela est simplement dans la nature de la plupart des développeurs dans la plupart des secteurs,
et traiter cela comme un manque de discipline n'a jamais donné de résultats. Ainsi, en tant que développeur, vous devez
attendre des tests qu'ils soient gratifiants et faire ce qu'il faut pour les rendre gratifiants.
Les tests développeur idéaux suivent une boucle éditer-tester très serrée. Vous apportez un petit changement au
produit, comme ajouter une nouvelle méthode à une classe, puis vous réexécutez les tests immédiatement. Si un test
échoue, vous savez exactement quel code en est la cause. Ce rythme simple et régulier de développement est ce qui est
le plus gratifiant dans les tests développeur. Les longues sessions de débogage doivent être exceptionnelles.
Etant donné qu'il n'est pas rare qu'un changement apporté dans une classe en dérange une autre, vous devez prévoir
d'exécuter à nouveau non seulement les tests de la classe modifiée mais de nombreux autres. Dans l'idéal, vous exécutez
à nouveau la suite de tests complète pour votre composant plusieurs fois par heure. Chaque fois que vous apportez un
changement important, vous exécuter de nouveau la suite, étudiez les résultats et soit procédez au changement suivant,
soit corrigez le dernier changement. Prévoyez de faire des efforts pour rendre cette remontée d'informations aussi
rapide que possible.
Automatiser vos tests
Exécuter des tests souvent n'est pas pratique si les tests sont manuels. Pour certains composants, les tests
automatisés sont faciles. Un exemple serait une base de données intégrée à la mémoire. Elle communique avec ses clients
à travers une API et ne possède aucune autre interface vers l'extérieur. Les tests pour cela ressembleraient à ce qui
suit :
/* Check that elements can be added at most once. */
// Setup
Database db = new Database();
db.add("key1", "value1");
// Test
boolean result = db.add("key1", "another value");
expect(result == false);
Seule une chose différencie ces tests du code client ordinaire : au lieu de croire les résultats des appels API, ils
vérifient. Si l'API facilite l'écriture du code client, elle facilite l'écriture du code de test. Si le code de test
n'est pas facile à écrire, vous avez reçu au début l'avertissement que l'API pouvaient être améliorée. La
Conception pilotée par le test est ainsi cohérente avec l'approche de Rational Unified Process, qui implique de se
concentrer sur la prise en considération précoce des risques importants.
Cependant, plus le composant est étroitement lié au monde extérieur, plus il sera difficile à tester. Il y a deux cas
courants : les interfaces utilisateur graphiques et les composants de back-end.
Interfaces utilisateur graphiques
Supposons que la base de données de l'exemple ci-dessus reçoive ses données via une procédure de rappel d'un objet
interface utilisateur. La procédure de rappel est appelée lorsque l'utilisateur remplit certaines zones de texte et
appuie sur un bouton. Vous n'avez certainement pas envie de tester cela en remplissant manuellement les zones et en
appuyant sur le bouton plusieurs fois par heure. Vous devez trouver un moyen de fournir l'entrée par le biais d'une
commande programmée, généralement en "appuyant" sur le bouton via du code.
Appuyer sur le bouton entraîne l'exécution d'un code dans le composant. Il est très probable que ce code change l'état
de certains objets de l'interface utilisateur. Vous devez donc prévoir également une façon d'interroger ces objets au
moyen d'un programme.
Composants de back-end
Supposons que le composant testé n'implémente pas une base de données. A la place, vous avez une classe enveloppante
autour d'une basse de données réelle sur disque. Les tests par rapport à cette base de données réelle peuvent être
difficiles. Elle peut être difficile à installer et configurer. Ses licences peuvent coûter cher. La base de données
peut suffisamment ralentir les tests pour vous décourager de les exécuter souvent. Dans ce cas-là, il vaut mieux
"tronçonner" la base de données et utiliser un module plus simple, suffisant pour les fins du test.
Ces modules de remplacement sont également utiles quand un composant avec lequel votre composant communique n'est pas
encore prêt. Vous ne voulez pas que vos tests dépendent du code de quelqu'un d'autre.
Pour plus d'informations, voir Concept : Modules de
remplacement.
Ne pas écrire ses propres outils
Les tests développeur semblent assez simples. Vous définissez des objets, vous effectuez un appel à travers une API,
vous vérifiez le résultat, et vous annoncez un échec de test si les résultats ne correspondent pas à vos attentes. Il
est également commode de disposer d'un moyen de grouper les tests afin de pouvoir les exécuter individuellement ou sous
forme de suites complètes. Les outils qui répondent à ces exigences s'appellent des infrastructures de test.
Les tests développeur sont simples et les exigences des infrastructures de test ne sont pas compliquées.
Toutefois, si vous succombez à la tentation d'écrire votre propre infrastructure de test, vous passerez davantage de
temps à optimiser votre infrastructure que vous ne le pensez. Plusieurs infrastructures de test sont disponibles, à
acheter ou gratuites, et il n'y a pas de raison de ne pas les utiliser.
Créer un code de support
Le code de test a tendance à être répétitif. Il est courant de voir des séquences de code comme celle-ci :
// null name not allowed
retval = o.createName("");
expect(retval == null);
// leading spaces not allowed
retval = o.createName(" l");
expect(retval == null);
// trailing spaces not allowed
retval = o.createName("name ");
expect(retval == null);
// first character may not be numeric
retval = o.createName("5allpha");
expect(retval == null);
Ce code est créé en copiant une vérification, en la collant, puis en l'éditant pour faire une autre vérification.
Le danger est ici double. Si l'interface change, il y aura un gros travail d'édition. (Dans des cas plus compliqués, un
simple remplacement global ne suffira pas.) De plus, si le code est très compliqué, l'objet du test peut se perdre au
milieu du texte.
Lorsque vous trouvez que vous vous répétez, envisagez sérieusement la mise en place d'un code de support. Même si le
code ci-dessus est un exemple simple, il est plus lisible et facile à entretenir s'il est écrit comme ceci :
void expectNameRejected(MyClass o, String s) {
Object retval = o.createName(s);
expect(retval == null); }
...
// null name not allowed
expectNameRejected(o, "");
// leading spaces not allowed.
expectNameRejected(o, " l");
// trailing spaces not allowed.
expectNameRejected(o, "name ");
// first character may not be numeric.
expectNameRejected(o, "5alpha");
Les développeurs qui rédigent des tests ont souvent tendance à recourir de trop au copier-coller. Si vous avez cette
tendance, il est utile de s'orienter volontairement dans l'autre direction. Vous devez essayer de supprimer tout le
texte en double de votre code.
Commencer par rédiger les tests
Ecrire les tests après le code est une corvée. Le travail est souvent bâclé pour terminer au plus vite et passer à la
suite. Ecrire les tests avant le code intègre les tests dans une boucle de remontée d'informations positive. Au fur et
à mesure que vous implémentez davantage de code, davantage de tests réussiront jusqu'à ce que finalement tous les tests
réussissent et que le travail soit terminé. Les personnes qui commencent par rédiger les tests semblent mieux réussir
et ne mettent pas plus de temps. Pour en savoir plus sur cette approche, voir Concept :
Conception pilotée par le test
Rédiger des tests compréhensibles
Il faut vous attendre à ce que vous ayez (vous ou quelqu'un d'autre) à modifier les tests ultérieurement. Par exemple,
une itération ultérieure peut appeler un changement dans le comportement du composant. Pour prendre un exemple simple,
supposons que le composant a une fois déclaré une méthode de racine carrée de cette façon :
double sqrt(double x);
Dans cette version, un argument négatif a conduit sqrt à renvoyer NaN ("not a number", selon la norme IEEE
754-1985 Standard for Binary Floating-Point Arithmetic). Dans la nouvelle itération, la méthode de racine carrée
acceptera les nombres négatifs et produira un résultat complexe :
Complex sqrt(double x);
Les anciens tests pour sqrt devront changer. Cela implique de comprendre ce qu'ils font et de les mettre à
jour afin qu'ils fonctionnent avec le nouveau sqrt. Lorsque vous mettez des tests à jour, vous devez veiller à
ne pas détériorer leur capacité à trouver les bogues. Il se passe parfois la chose suivante :
void testSQRT () {
// Update these tests for Complex
// when I have time -- bem
/* double result = sqrt(0.0); ... */ }
D'autres moyens sont plus subtils : les tests sont changés de manière à ce qu'ils s'exécutent mais qu'ils ne testent
plus ce pour quoi ils ont été conçus à l'origine. Après un grand nombre d'itérations, on peut obtenir une suite de
tests n'ayant pas la capacité de déceler de nombreux bogues. On appelle parfois cela "déclin de la suite de tests". Une
suite en déclin sera abandonnée car elle ne vaut plus la peine d'être maintenue.
Vous ne pouvez pas conserver la capacité d'un test à trouver les bogues si les Idées de test
que ce test implémente ne sont pas claires. Le code du test est souvent accompagné de peu de commentaires, même s'il
est plus difficile d'en comprendre la logique que pour un code de produit.
Il est moins probable que le déclin de la suite de tests se produise pour sqrt dans des tests directs que dans des
tests indirects. Il y aura toujours du code qui appelle sqrt. Ce code aura des tests. Quand sqrt changera,
certains de ces tests échoueront. La personne qui a changé sqrt devra probablement changer ces tests. Parce
qu'elle les connaît moins, et parce que leur relation avec le changement est moins claire, cette personne court plus de
risques d'affaiblir les tests en essayant de faire en sorte qu'ils réussissent.
Lorsque vous créez du code de support pour les tests (comme nous vous l'avons conseillé ci-dessus), soyez attentif : le
code de support doit clarifier et non pas obscurcir l'objet des tests qui l'utilisent. On se plaint souvent que dans
les programmes orientés objet, il n'existe aucun emplacement unique où tout peut être fait. Si vous regardez une
méthode, vous constaterez qu'elle transmet son travail ailleurs. Une telle structure présente des avantages, mais elle
rend la compréhension du code plus difficile pour les nouvelles personnes. A moins qu'elles fassent un effort, leurs
changements ont des chances d'être erronés ou le code risque d'être plus compliqué et fragile. Il en va de même pour le
code du test, sauf que les autres intervenants sont encore moins susceptibles de s'en occuper comme il se doit. Vous
devez éviter le problème en rédigeant des tests compréhensibles.
Adapter la structure du test à la structure du produit
Supposons que quelqu'un hérite de votre composant. Supposons également qu'il ait besoin d'en changer une partie. Il
souhaitera peut-être examiner les anciens tests pour l'aider dans sa nouvelle conception. Il commencera par mettre à
jour l'ancien test avant d'écrire le code (Conception pilotée par le test).
Toutes ces bonnes intentions seront vaines s'il ne peut pas trouver les tests adéquats. Ce qu'il fera, c'est apporter
le changement, voir si le test échoue et le corriger. Cela contribue au déclin de la suite de tests.
Pour cette raison, il est important que la suite de tests soit bien structurée et que l'emplacement des tests soit
prévisible à partir de la structure du produit. Le plus couramment, les développeurs organisent les tests selon une
hiérarchie parallèle, avec une classe de test par classe de produit. Ainsi, si quelqu'un change une classe nommée
Log, il sait que la classe de test est TestLog, et il sait où trouver le fichier source.
Laisser les tests accéder à l'encapsulation
Vous pouvez limiter vos tests à l'interaction avec votre composant, exactement comme le code client, à travers la même
interface que le code client. Toutefois, cela présente des inconvénients. Supposons que vous testiez une classe simple
qui maintient une liste doublement liée :
Fig1 : Liste à deux liens
Notamment, vous testez la méthode DoublyLinkedList.insertBefore(Object existing, Object newObject). Dans l'un
de vos tests, vous devrez insérer un élément au milieu de la liste, puis vérifier s'il a été inséré avec succès. Le
test utilise la liste ci-dessus pour créer cette liste mise à jour :
Fig2 : Liste à deux liens - élément inséré
L'exactitude de la liste est vérifiée comme suit :
// the list is now one longer.
expect(list.size()==3);
// the new element is in the correct position
expect(list.get(1)==m);
// check that other elements are still there.
expect(list.get(0)==a); expect(list.get(2)==z);
Cela semble suffisant mais ne l'est pas. Supposons que l'implémentation de la liste est erronée et que les pointeurs de
retour sont correctement réglés. C'est-à-dire, supposons que la liste mise à jour ressemble en fait à cela :
Fig3 : Liste à deux liens - erreur d'implémentation
Si DoublyLinkedList.get(int index) traverse la liste du début à la fin (probable), le test ne verra pas cette
anomalie. Si la classe fournit les méthodes elementBefore et elementAfter, la vérification de ces
anomalies est facile :
// Check that links were all updated
expect(list.elementAfter(a)==m);
expect(list.elementAfter(m)==z);
expect(list.elementBefore(z)==m);
//this will fail
expect(list.elementBefore(m)==a);
Mais que se passe-t-il si elle ne fournit pas ces méthodes ? Vous pourriez concevoir des séquences plus élaborées
d'appels de méthodes qui échoueront si l'anomalie suspectée est présente. Par exemple :
// Check whether back-link from Z is correct.
list.insertBefore(z, x);
// If it was incorrectly not updated, X will have
// been inserted just after A.
expect(list.get(1)==m);
Mais la création d'un tel test représente plus de travail et sera probablement plus difficile à maintenir. (A moins que
vous n'écriviez de bons commentaires, personne ne comprendra pourquoi le test fait ce qu'il fait.) Il existe deux
solutions :
-
Ajouter les méthodes elementBefore et elementAfter à l'interface publique. Mais cela expose
l'implémentation à tout le monde et rend les changements ultérieurs plus difficiles.
-
Laisser les tests 'regarder sous le capot' et vérifier directement les pointeurs.
Cette dernière solution est la meilleure, même pour une classe simple comme DoublyLinkedList et notamment pour
les classes plus complexes qui se présentent dans vos produits.
Généralement, les tests sont mis dans le même package que la classe qu'ils testent. On leur attribue un accès protégé
ou 'friend' (interne).
Erreurs classiques de conception de test
Chaque test est dédié à un composant et vérifie si les résultats sont corrects. La conception du test (les intrants
qu'il utilise et comment il vérifie l'exactitude) peut être pertinente pour révéler les anomalies, ou elle peut les
masquer par inadvertance. Voici quelques erreurs classiques de conception de test.
Incapacité à spécifier à l'avance les résultats attendus
Supposez que vous testez un composant qui convertit le format XML en HTML. Vous pouvez être tenté de prendre des
fichiers XML, de les convertir et de regarder le résultat dans un navigateur. Si l'écran semble correct, vous "validez"
le fichier HTML en l'enregistrant comme résultat attendu officiel. Ensuite, un test compare le véritable résultat de la
conversion aux résultats attendus.
Ceci est une pratique dangereuse. Même les utilisateurs avertis ont l'habitude de croire ce que fait l'ordinateur. Il
est probable que des erreurs dans l'apparence de l'écran vous échappent. (Sans mentionner le fait que les navigateurs
sont assez tolérants aux fichiers HTML mal formatés.) En désignant ce fichier HTML erroné comme le résultat attendu
officiel, vous pouvez être sûr que le test ne trouvera jamais le problème.
Il est moins dangereux de vérifier deux fois en regardant directement le fichier HTML mais cela reste toutefois
dangereux. Parce que le résultat est compliqué, il sera facile d'oublier des erreurs. Vous trouverez plus d'anomalies
si vous commencez par écrire manuellement le résultat attendu.
Incapacité à vérifier l'arrière-plan
Les tests vérifient généralement que tout ce qui devait être changé l'a été, mais leurs créateurs oublient souvent de
vérifier que ce qui devait rester inchangé l'est resté. Par exemple, supposons qu'un programme soit censé changer les
100 premiers enregistrements d'un fichier. C'est une bonne idée que de vérifier que le 101ème n'a pas été
changé.
En théorie, vous devez vérifier que rien dans l'arrière-plan (le système de fichiers entier, toute la mémoire, tout ce
qui est accessible à travers le réseau) n'a été oublié. Dans la pratique, vous devez choisir avec soin ce que vous
pouvez vous permettre de vérifier. Mais il est important de faire ce choix.
Incapacité à vérifier la persistance
Lorsqu'un composant vous dit qu'un changement a été fait, cela ne signifie pas nécessairement qu'il a été réellement
apporté dans la base de données. Vous devez vérifier la base de données d'une autre façon.
Incapacité à assurer la diversité
Un test peut être conçu pour vérifier l'effet de trois champs dans un enregistrement de base de données, mais beaucoup
d'autres champs doivent être renseignés pour exécuter le test. Les testeurs vont souvent réutiliser les mêmes valeurs
pour ces champs peu importants. Par exemple, ils utiliseront toujours le nom de leur fiancée dans une zone de texte ou
999 dans une zone numérique.
Le problème est que parfois, ce qui ne devrait pas avoir d'importance en a dans la réalité. Parfois, il y a un bogue
qui dépend d'une combinaison d'entrées improbables. Si vous utilisez toujours les mêmes entrées, vous n'avez aucune
chance de trouver ces bogues. Si vous changez les entrées en permanence, vous pourrez peut-être les découvrir. Assez
souvent, il ne coûte presque rien d'utiliser un chiffre différent de 999 ou le nom d'une autre personne. Utiliser des
valeurs variées dans un test ne coûte presque rien et présente certains avantages, alors pensez-y. (Remarque : il est
peu recommandable d'utiliser le nom de vos ex si votre fiancée du moment travaille avec vous.)
Cela présente un autre avantage. Une erreur plausible est que le programme utilise le champ X alors qu'il aurait
dû utiliser le champ Y. Si les deux champs contiennent "Danielle", l'erreur ne peut être détectée.
Incapacité à utiliser des données réalistes
Il est courant d'utiliser des données inventées dans les tests. Ces données sont souvent incroyablement simples. Par
exemple, des noms de clients comme "Mickey", "Snoopy" ou "Donald". Parce que ces données ne correspondent pas à ce
qu'un véritable utilisateur peut saisir (par exemple, parce que les données sont plus courtes), des anomalies qu'un
véritable client rencontrera peuvent échapper au test. Par exemple, ces noms en un mot ne détecteront pas que le code
ne prend pas en charge les noms composés comportant un espace.
Il est conseillé de faire un petit effort supplémentaire et d'utiliser des données réalistes.
Incapacité à remarquer que le code ne fait rien
Supposez que vous initialisez un enregistrement de base de données à zéro, que vous exécutez un calcul qui doit faire
en sorte que zéro soit enregistré dans l'enregistrement, puis que vous vérifiez que l'enregistrement a bien la valeur
zéro. Qu'est-ce que votre test a démontré ? Le calcul peut très bien ne pas s'être effectué du tout. Il est possible
que rien n'ait été enregistré et le test n'a pas pu le détecter.
Cet exemple semble impossible. Mais cette même erreur peut se présenter de façon plus insidieuse. Par exemple, vous
pouvez rédiger un test pour un programme d'installation compliqué. Le test vise à vérifier que tous les fichiers
temporaires sont supprimés une fois l'installation terminée. Mais, en raison de toutes les options d'installation, dans
ce test, un fichier temporaire en particulier n'a pas été créé. Il est certain que c'est celui que le programme
oubliera de supprimer.
Incapacité à remarquer que le code n'effectue pas la bonne action
Parfois, un programme exécute l'action adéquate, mais pour les mauvaises raisons. Prenons ce code simple comme exemple
:
if (a < b && c)
return 2 * x;
else
return x * x;
L'expression logique est erronée, et vous avez créé un test qui entraîne une évaluation incorrecte et le choix d'une
mauvaise branche. Malheureusement, par pure coïncidence, la variable X a la valeur 2 dans ce test. Le résultat de la
mauvaise branche est correct par accident - il est identique à celui qu'aurait donné la bonne branche.
Pour chaque résultat attendu, vous devez vous demander s'il existe une façon plausible d'obtenir ce résultat pour la
mauvaise raison. Bien que cela soit souvent impossible à savoir, cela est parfois réalisable.
|