Concept: Test développeur
Ces instructions fournissent quelques conseils pour franchir les premiers obstacles lors de création de tests développeur et pour la création d'une suite de tests qui puissent être maintenus tout au long du projet. Vous trouverez aussi des conseils pour créer des tests développeur plus performants.
Relations
Eléments connexes
Description principale

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 :

Image d'un exemple de liste à deux liens

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 :

Image d'exemple de liste à deux liens avec élément inséré

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 :

Image d'exemple de liste à deux liens avec erreur d'implémentation

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 :

  1. 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.
  2. 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.