Introduction

Développement dirigé par les tests

Le développement dirigé par les tests (Test Driven Development - TDD en anglais) est une approche moderne de la productivité logicielle. Cela consiste à écrire les tests avant de développer. Ecrire les tests conduit à utiliser notamment les interfaces de programmation que l’on désire avant même de les déclarer. Un outil comme Eclipse facilite cette approche, car il propose la création automatique de squelettes de code en cas d’absence des élément utilisés. C’est une approche descendante qui réduit la complexité, et permet la définition des exigences de manière incontournable.

La conception par contrat

La conception par contrat (Design by Contract - DBC en anglais) introduite avec le langage Eiffel, consiste quand à elle à valider à l’exécution les invariants d’un programme, qu’il s’agisse d’invariants de classes, de pré ou post conditions ou autres. Il s’agit d’un contrat dans le sens ou sous réserve du respect par l’appelant de ses préconditions, l’appelé s’engage dans ses postconditions et ses produits.

On utilise habituellement l’expression ’assert’ pour tester les invariants. Par exemple une implantation de la fonction ’divise’ peut ressembler à:

float divise (float a, float b){
assert b!= 0;
return a/b;
}

Les invariants sont des tests

Il existe un lien naturel entre le DBC et l’approche TDD dans le sens où les invariants devraient être définis et implantés avant le code. On voit bien que les invariants vérifiés automatiquement pendant l’exécution en version ’debug’ sont autant de tests que le programme passe constamment.

Utilisation directe de ’assert’ pour TDD

On teste les produits et les postconditions

Lorsqu’on parle de test, on considère en premier lieu le fait de vérifier qu’un programme fonctionne normalement, c’est à dire que ses produits sont corrects, et que ses post-conditions affichées sont respectées. Tout cela se teste facilement avec des constructions de programmation classiques pour le test automatique, comme ’assert’. Par exemple, si une fonction ’f()’ doit retourner la valeur ’4’, on écrira

assert f()==4;

On teste que les exceptions attendues sont lancées

Les exceptions lancées par un programme participent à la fonctionnalité. Un programme de test unitaire exhaustif doit valider les situations qui provoquent ce type d’événements, le cas échéant au moyen de simulations de l’environnement. Ces tests là demandent en Java d’utiliser la construction d’interception d’erreurs

try {
/* code */
assert false:"Must not happen";
}
catch (MyException e){
assert e.getMessage().equals (...);
...
}

On teste que les exceptions attendues sont lancées

Si une fonction appelée dans le test déclare une exception (au moyen de l’instruction ’throws’), celle ci doit être soit interceptée par le programme, soit renvoyée par ’main’. Dans le premier cas, on peut indiquer clairement que l’exception ne doit pas être lancée dans des conditions normales d’exécution:

try {
/* code */

}
catch (MyException e){
assert false:"Must not happen";
}

, et d’utiliser ’assert’ dans le bloc ’catch’ de manière à vérifier le type exact et les propriétés de l’exception lancée.

On teste que les pré conditions sont testées

Plus généralement, dans la mesure où les pré-conditions requises pour l’exécution d’un programme sont affichées publiquement, on comprend qu’il faille également vérifier que le programme les teste effectivement en interne. En effet, garantie est donnée au monde extérieur qu’un non respect des préconditions provoquera une interruption de l’exécution dans la version de debug. Le valider dans un programme de test unitaire complète utilement la documentation implicite fournie dans le source par les invariants correspondants (le source n’est pas la première source d’information des programmeurs). Ces tests là demandent également en Java d’utiliser la construction d’interception d’erreurs :

try {
/* code */
throw new RuntimeException ("Must not happen");
}
catch (AssertionError e) {
assert e.getMessage().equals (...); ...
}

Utilisation de try/finally pour la validation du contrat

Définition de l’invariant de classe

Au coeur de la conception par contrat dans les langages orientés objets figure l’invariant de classe. Cet invariant décrit l’ensemble des propriétés qui sont vraies d’un objet du moment où sa construction est terminée jusqu’à sa finalisation. L’invariant d’une classe ’Pile’ offrant l’API ’push’, ’pop’ sur la base d’un tableau natif pourra être décrit comme suit:

class Pile{
int tab[];
int pos=0;
void invariant(){
assert(tab!=null) ;
assert(tab.length>=pos);
assert(pos>=0);
}

...
}

Appel de l’invariant de classe

On doit généralement tester l’invariant de classe à l’entrée de chaque méthode de la classe. Cela garantira que l’objet auquel on applique le calcul satisfait ces exigences minimales. On doit de plus tester de nouveau cet invariant quand la fonction en question modifie l’état de l’objet. Cela permettra notamment de détecter au plus tôt des bugs de régression nés de l’évolution d’une implantation, ainsi que de détecter des anomalies générés par le fait que des exceptions se produisent pendant l’exécution du code. L’invariant sera donc testé en tout premier lieu dans une méthode - on peut le placer sur la même ligne que la déclaration de la fonction de manière à renforcer sa visibilité -, et en fin d’exécution. Pour ce deuxième cas, il est nécessaire d’utiliser un bloc try ... finally ... afin de garantir l’appel en sortie sans devoir modifier le code d’origine.

void push(int v) { invariant();
try {
tab[pos++]=v;
}
finally {
invariant() ;
}

}

Dans les accesseurs, on juger inutile de tester l’invariant en sortie. Dans ce cas la construction try ... finally ... est inutile. Toutefois, cela ne se justifie que dès lors qu’aucune section du code n’est susceptible d’effet de bord en cas de bug, ni qu’aucune section du code ne modifie temporairement l’état de l’objet, auquel cas une exception pourrait dégrader l’état de l’objet.

Pré conditions

Les préconditions seront placées au début de la fonction, après l’appel de l’invariant, et avant le ’try’.

int pop(){ invariant();
assert(pos!=0);// assert(!isEmpty();
try{
return tab[--pos];
}
finally{
invariant() ;
}
}

Post conditions

Les post-conditions seront placées au sein du bloc finally, après l’appel de l’invariant.

int pop() { invariant();
assert pos!=0;// assert !isEmpty();
try{
return tab[--pos];
}
finally {
invariant() ;
assert !isFull();
}
}

Delta conditions

Un type particulier de post condition évalue la manière dont l’état d’un objet a été altéré pendant l’exécution de la méthode. Par exemple dans le cas de la Pile, après exécution de ’push’ la valeur de la position courante a été incrémentée de 1. Ce type de condition demande de stocker dans une variable auxiliaire avant le ’try’ les informations requises pour la comparaison finale réalisée dans le ’finally’.

void push(int v) { invariant();
int oldpos=pos; try {
tab[pos++]=v;
}
finally {
invariant() ;
assert pos==oldpos+1 ;
assert ! isEmpty() ;
}
}

Eviter qu’une exception ne soit masquée par l’invariant de classe ou les post conditions

Pour finir, nous devons considérer le cas ou des exceptions ou assert sont levés dans le code de la fonction. La difficulté dans ce cas est que l’invariant de classe ou une post condition peut se trouver en échec. Le réveil d’un assert dans le finally aura pour effet de masquer le ’Throwable’ précédent. Il est nécessaire de pouvoir être informé des deux (ou plus) événements.

En effet, d’une part l’assert ou exception responsable de l’interruption du programme doit pouvoir être testé dans le programme de test unitaire comme vu plus haut. Mais il est également nécessaire de savoir qu’une classe n’est pas ’Exception Safe’. Normalement, si une exception provoque le passage d’un objet dans un état invalide, c’est qu’elle devrait être traitée dans la méthode.

Il n’existe pas à ma connaissance de moyen de savoir au sein du bloc finally si un ’Throwable’ est en cours de propagation. Il faut donc intercepter les erreurs non soumises à obligation de déclaration avant le bloc ’finally’.

assert pos!=0;// assert !isEmpty();
try{
return tab[--pos];//may trigger an ArrayIndexOutOfBoundsException
} catch (Error e) { e.printStackTrace(); throw e;
} catch (RuntimeException e) { e.printStackTrace(); throw e;
} finally {
invariant() ;
assert !isFull();
}
}

Références

Test Driven Development sur Wikipedia

Design by Contract sur Wikipedia

Exemple d’application: la classe SortedIntArray

Vous trouverez ci dessous les fichiers sources d’une classe de démonstration de ces concepts. La classe SortedIntArray est intégralement instrumentée en invariants selon les principes généraux de la conception par contrat et au moyen des constructions try ... finally ... proposées plus haut. De plus un programme de test unitaire est implanté dans la fonction statique main qui tente de couvrir de manière exhaustive les cas d’utilisation de la classe SortedIntArray. Ce programme de test est réalisé selon les principes du développement dirigé par les tests (TDD), et est présenté en haut du fichier pour insister sur la priorité à donner à la définition des tests et des API dans la programmation.