Programmation:TestsEtCommentaires

De wiki-prog
Aller à : navigation, rechercher

Introduction

Si la conception et la réalisation d'un programme représentent une part importante de l'activité du programmeur, il ne faut pas oublier que ces activités doivent impérativement prévoir les problématiques de validation et de maintenance du code.

La validation et la maintenance du code repose sur trois concepts importants:

  • Les tests;
  • La mise en forme du code;
  • La documentation du code et du projet.

Ces activités sont transversales à la réalisation et à la conception (elles interviennent donc à presque toutes les étapes du processus) et ne doivent pas être négligées.

Tester et valider

Un programme ne peut être considéré comme fini que s'il a été validé. Par validé, on entend plusieurs choses importantes:

  • Le programme satisfait les attentes initiales;
  • Le programme se comporte correctement dans le cadre défini par les attentes initiales (i.e. il ne plante pas en utilisation normale);
  • Les contraintes de fonctionnement induites par la réalisation sont pleinement définies et acceptées par le commanditaire.

L'un des termes importants que nous venons de citer est attentes initiales, ce terme, bien que un peu vague, est un point crucial du processus de validation. Un programme marche lorsqu'il satisfait les attentes (clairement définies) de son commanditaire. Il est donc important que le programme fasse se que l'utilisateur final attend de lui (en tout cas fasse ce que l'utilisateur final a demandé) et qu'il se comporte correctement dans le cadre d'une utilisation normale.

Ce point détermine le début de la phase de tests:

Les tests commencent dès la rédaction du cahier des charges !

Pour s'assurer que le processus de validation du produit fini satisfasse les attentes initiales, on rédige les tests finaux en même temps que le cahier des charges. Pour se faire, une bonne stratégie consiste à écrire des scénarios d'utilisation du programme en accord avec le commanditaire. Le processus de validation finale consistera dans ce cas à tester ces scénarios avec le programme.

Scénarios

Un scénario d'utilisation n'a pas de forme particulièrement établie, il peut s'agir d'un texte en langage naturelle ou de diagrammes temporels décrivants le déroulement du scénario. Par contre, certains points doivent être respectés:

  • un scénario décrit une seule tâche clairement identifiée et si possible sans dépendre d'autres tâches;
  • le comportement et le résultat de la manipulation décrite dans le scénario doivent être définis aussi précisement que possible;
  • un scénario ne doit pas être écrit en fonction de la réalisation technique mais des attentes de l'utilisateur.

Comme pour toute forme de tests, il faut définir autant le cadre du test que le résultat attendu. Il est important d'identifier ce qui est testé dans le scénario et de segmenter le plus possible les tests effectués, sinon leur résultat ne seront pas exploitables.

Un bon scénario est également un bon outil de choix, en effet, il ne doit dépendre que des attentes de l'utilisateur, pas des choix de conception. En théorie, on devrait pouvoir mettre en œuvre un même scénario sur des solutions différentes répondants aux mêmes attentes.

Sur de gros projets, il peut également être intéressant d'exploiter les scénarios d'utilisation afin de présenter les maquettes, prototypes et autres démonstrateurs au commanditaire. Si les scénarios sont pertinents, il pourra ainsi se faire une idée du comportement final de la solution retenue.

Prennons un petit exemple 

Nous désirons réaliser une application de carnet d'adresses. Pour ça nous allons décrire un petit scénario simple:

  • L'utilisateur démarre l'application pour la première fois, le carnet d'adresse est vide;
  • L'utilisateur désire ajouter une entrée dans le carnet:
    • Il remplit la fiche de renseignement avec un nom, un prénom et une adresse mail;
    • Il ne désire pas associer l'entrée avec un groupe particulier;
  • L'utilisateur sauve son ajout et quitte l'application;
  • L'utilisateur relance l'appliaction et retrouve le contact précédemment rentré.

Tests techniques

La validation finale n'est pas le seul processus de validation d'un programme. En fait, avant de s'assurer que le programme satisfait l'utilisateur, il faut s'assurer qu'il marche. Pour ça, il faut des tests techniques.

La rédaction de cahiers de tests est assez similaire à la rédaction des scénarios: les tests sont décrits en amont de la réalisation en fonction des spécifications et de la conception mise en œuvre et servent à s'assurer que le code correspond bien à sa spécification.

Un bon test satisfait un peu les mêmes caractéristiques qu'un bon scénario:

  • chaque test ne s'occupe que d'un seul comportement bien défini;
  • le context dans lequel le test va s'appliquer doit être clairement défini,
  • le résultat attendu doit être aisément vérifiable;
  • le test ne doit pas dépendre de la réalisation.

Comme pour les scénarios, les tests doivent couvrir un maximum de situations pour être pertinents, par contre il existe des guides pour s'assurer qu'un test couvre suffisament de cas. En règles générales, on recherche les cas correspondants aux critères suivants:

  • Cas par défaut: si l'élément testé peut être utilisé avec un (ou plusieurs) contexte par défaut;
  • Cas extrèmes: lorsque les contextes d'utilisation (arguments, environnement ... ) peuvent s'exprimer comme un intervalle, il faut tester les bornes de cet intervalle;
  • Cas remarquables: tous les contextes d'utilisation ayant un comportement spécifique (décrit dans les spécifications) doivent être testés;
  • Cas réels: enfin, on termine par des contextes issus (si possible) de cas concrets ou en tout cas inspirés de situation concrète.

Enfin, on élimine tous les cas improbables ou les cas qui sont explicitement exclus dans les spécifications. L'idéal serait de déterminer en fonction du contexte d'utilisation, quels cas ne se présenterons jamais (mais ce n'est pas toujours simple, ni même possible.) Lorsque les spécifications décrivent un comportement exceptionnel pour des cas particuliers (erreurs, exceptions ... ), ces cas doivent évidement être testés.

Prennons un petit exemple

Nous désirons tester la fonction insert(x,l) qui insère à sa place un élément à une liste triée sans redondance. On suppose que l'on dispose des opérations pour tester la présence d'un élément (mem(x,l)) et pour vérifier que la liste est bien triée (sorted(l)), un test pour savoir si la liste est vide (is_empty(l)), une fonction donnant la taille de la liste (len(l)) et un constructuer renvoyant une nouvelle liste vide (empty().) Voici une liste de test à effectuer définis en pseudo code:

Liste vide
/* On commence avec la liste vide */
l = empty();
/* On insert une valeur remarquable comme 0 ou 1 */
insert(0,l);
/*
 * Lorsque c'est possible, on utilise les assertions qui produirons
 * une erreur sur les tests échoués.
 */
/* La liste ne doit pas être vide */
assert(!is_empty(l));
/* L'élément doit être dans la liste */
assert(mem(x,l));
/* la liste doit contenir 1 élément */
assert(len(l) == 1);
Insertion d'un nouvel élément
/*
 * Il est rarement possible de construire correctement une liste de
 * test à la main, on effecturera donc les tests avec une liste
 * construite à la volée.
 */
l = empty();
/*
 * on insert dans le même ordre que le tri de la liste avec des "trous"
 */
for (i=1; i<20; i += 2)
{
  insert(i,l);
  assert(mem(i,l));
  /* On pourrait insérer plus de valeurs et ne faire ce test qu'en fin */
  assert(sorted(l));
}
/* On test les cas extrèmes */
insert(0,l);
assert(mem(0,l));
assert(sorted(l));
insert(20,l);
assert(mem(20,l));
assert(sorted(l));
/* On test une valeur simple entre les éléments présents */
insert(10,l);
assert(mem(10,l));
assert(sorted(l));
/* Enfin on essaie d'insérer un élément déjà présent */
nb = len(l);
insert(10,l);
assert(mem(10,l));
assert(sorted(l));
assert(nb == len(l));

Si une opération de type assert n'est pas disponible, le mieux est d'écrire un équivalent. Une telle fonction doit répondre aux besoins suivants:

  • Arrêter le programme si le booléens passé est faux;
  • En cas d'arrêt, afficher une indication claire de la position du tests;
  • Ne rien faire si le booléens est vrai.

Modularité et validation

Le processus de validation doit suivre vos choix de modularisation. Si votre application est bien découpée, vous pourrez améliorer l'ensemble du processus de validation.

On trouve un bon exemple d'interractions fructueuses entre modularisation et validation lorsque l'on construit des structures de données abstraites: en séparant la spécification et l'implantation, on peut valider les modules exploitants ces structures de données indépendamment de la validation de ces structures.

On parle de diminuer le couplage entre les modules. Cette notion (en anglais loose coupling) est un élément important des approches modernes de design. En diminuant l'interdépendance entre modules (ou tout autres entités de votre langage) on augmente les possibilité de réutilisation d'un bloc de code (au sein d'un même projet ou pas), on améliore et on simplifie la phase de tests et parfois on peut également simplifier les phases de reconstruction (compilations, éditions de liens ... )

Exemple

Nous allons nous intéresser à deux entités abstraites (modules): un algorithme de plus court chemin (comme l'algorithme de Dijkstra) et une structure de donnée permettant de représenter des ensembles. Le but n'est pas de faire les meilleurs choix possibles mais d'exposer les avantages d'une bonne segmentation.

Considérons tout d'abord la structure d'ensembles (que l'on suppose ordonnées.) Les ensembles ainsi définis ne correspondent pas tout à fait aux besoins de notre algorithme, mais l'adaptation est minime. Il nous faut quelques opérations classiques:

  • empty() renvoie un ensemble vide
  • is_empty(e) test si un ensemble est vide
  • insert(x,e) insert x dans e (sans redondance et à sa place)
  • get_min(e) renvoie le plus petit élément de l'ensemble
  • union(e1,e2) réalise l'union des deux ensembles
  • mem(x,e) indique si x est dans l'ensemble

Il existe de nombreux choix possibles pour implanter ces ensembles. Dans un premier temps, nous choisirons la méthode la plus simple, des listes chaînées ordonnées.

Il existe pour chaqu'une des ces opérations, des algos simples qui reposent sur un seul invariant: la liste en entrée est correcte, c'est à dire triée et sans doublon. Si cette invariant est respecté en entrée, alors les opérations le respecteront en sortie (comprendre que la liste résultat sera également triée et sans doublon.)

Le point important ici est que la propriété qui nous intéresse n'est pas inhérente à la structure de donnée. Notamment, si notre langage fournit des listes d'origine, il sera possible de modifier ou de construire une liste de manière non conforme. Par contre, si l'on assure une parfaite abstraction, l'implantation du module sera opaque pour les autres modules et seules les opérations définies dans celui-ci permettrons la création ou la modification des ensembles.

Une fois le module validé, comme les autres modules ne peuvent pas mal utiliser le module en question: il n'est plus nécessaire de tester son intégration dans le projet.

Regardons maintenant notre algorithme de plus court chemin (une fois ajouté les opérations nécessaires sur les ensembles), nous pouvons le tester avec notre module d'ensembles. Si l'algorithme n'utilise que l'interface abstraite du module, la validation de l'algorithme ne dépend pas de l'implantation du module d'ensembles, mais de son respect des invariants attendus.

Par conséquent, il est possible de changer d'implantation pour les ensembles sans avoir à revalider l'algorithme. On peut ainsi remplacer nos listes par une implantation plus efficace (comme un tas binaire) sans avoir à retester l'algo principal.

Comme on a pu le voir, une bonne modularité peut être un atout pour une phase de validaiton plus simple mais aussi plus efficace.

Lisibilité, Organisation et Commentaires

Un point souvent négligé dans la programmation est la mise en forme du code. On a tendance à voir cette partie comme indépendante du code, pourtant il n'en est rien. Un code bien écrit est un code simple à maintenir ou à corriger, mais c'est aussi un code bien pensé.

Il n'existe pas de règles de mise en forme magiques s'appliquant à tous les cas. Par contre, il y a quelques choix de bon sens:

  • Indenter
  • Uniformiser votre mise en page;
  • Uniformiser (au mieux) vos symboles;
  • Regrouper les définitions par thèmes
  • Cadrer votre mise en page (longueur des lignes, indentation ... )
  • Indenter (bis, au cas où)
  • Ne pourrissez pas votre code avec un afflux de commentaires inutiles

Que du bon sens me direz-vous ! Mais ce bon sens est important. Détaillons tout de même l'ensemble de ces points.

Indentation
Il s'agit de décaller votre code pour refléter sa structure. Quelque soit votre langage, l'indentation est primordiale pour la lisibilité et la compréhension du code.
L'indentation améliore la lisibilité du code bien sûr, mais c'est aussi un atout important pour éviter les erreurs.
Si votre éditeur est capable d'indenter intelligement votre code (en se basant sur la syntaxe, pas juste sur la ligne précédante) profitez en un maximum !
Uniformiser
Il n'est pas rare que des projets (ou des écoles) imposent une charte de mise en forme (coding style, norme ... ) Ces charte (parfois très contraignantes) n'ont qu'un seul objectif: faire en sorte que tout le code du projet se ressemble. Si vous êtes seul (ou que vous avez le contrôle de ce genre de contraintes) vous n'êtes pas forcément obligé de vous imposer une telle charte. Par contre, à l'inverse, un code non uniforme est particulièrement illisible.
Dans les conventions usuelles, la principales concerne la position des accolades (ou tout autre caractère d'élimitant les blocs.) Il n'y a pas de bonne position pour une accolade ouvrante, si ce n'est la même que la précédante ! En d'autres termes, peu importe que vous ouvriez vos accolades en fin de ligne ou en début de ligne, du moment que vous les ouvrez toujours de la même façon.
Dans le même ordre d'esprit, si vous adoptez des conventions pour vos noms de symboles soyez homogènes: si vous décidez de nommer vos types avec un préfixe t_, ne choisissez pas de nommer vos structure avec un suffixe _s, mettez aussi un préfixe !
Regrouper
Un code est un ensemble structuré de définitions. Même si ces définitions peuvent être posées dans n'importe quel ordre, faîtes en sorte qu'il y est une cohérence dans cet ordre ! Là encore, l'ordre importe peu, du moment que la même logique est appliquée partout.
Cadrer la mise en page
Un point que l'on oublie souvent: tous les écrans n'ont pas la même géométrie, tout le monde n'utilise pas la même taille de caractères ... Du coup, un code qui pourrait sembler lisible chez vous sera illisible dans d'autres conditions.
Pour éviter les problèmes, fixer une taille raisonable à vos lignes. Une ligne trop longue est souvent peu lisible, il est plus simple de la couper en choisissant une délimiteur logique (opérateur, séparateur ... ) et en alignant les parties.
Dans le même esprit, éviter d'indenter trop fortement, deux espaces suffisent amplement. Éviter également l'usage du caractère de tabulation, celui-ci n'est pas toujours afficher pareil.

Les commentaires

Les commentaires sont un vaste sujet de la programmation. On raconte beaucoup de chose sur leur usages, la quantité, la position, la mise en forme ...

Dans un article très intéressant de Rob Pike[1], on trouve quelques remarques surprenantes sur les commentaires. Notamment:

I tend to err on the side of eliminating comments, for several reasons.  First, if the code is clear, and uses good type names and variable names, it should explain itself ...

En gros, un code clair ne nécessite pas de commentaire. Cela peut paraître surprennant, mais je suis du même avis.

Les commentaires sur comment ce code marche, sont presque toujours inutiles, ils décrivent maladroitement ce que le code décrit formellement (et donc sans ambiguïté.)

Documentation

Références

  1. Rob Pike Notes on Programming in C[1], 1989-02-21