Tests unitaires et vérification d'invariances - exemples en C#

Bruno Orsier

Cet article décrit plusieurs exemples de tests unitaires qui exploitent un principe de vérification d'invariances.

- Titre : Tests unitaires et vérification d'invariances - exemples en C#

- Auteur : Bruno Orsier

- Parution : 28 octobre 2008

- Mise à jour : 3 juin 2020

- Licence : Les sources présentées sur cette page sont libres de droits et vous pouvez les utiliser à votre convenance. Par contre, la page de présentation constitue une œuvre intellectuelle protégée par les droits d'auteur. Copyright ® 2008 Bruno Orsier. Aucune reproduction, même partielle, ne peut être faite de ce site et de l'ensemble de son contenu : textes, documents, images, etc. sans l'autorisation expresse de l'auteur. Sinon vous encourez selon la loi jusqu'à trois ans de prison et jusqu'à 300 000 € de dommages et intérêts.

Sommaire

- I. Introduction

- II. Invariance d'un résultat de calcul

- III. Invariance d'une caractéristique

- IV. Invariance lors de la persistance

- V. Invariance par inversion et autres opérations

- Conclusion

- Remerciements

I - Introduction

Quand je passe en revue les tests unitaires écrits sur nos applications, je constate une énorme proportion de tests du type « tel calcul doit donner précisément telle valeur ». Ce type de test est bien sûr tout à fait nécessaire. Toutefois je constate que d'autres tests ne sont pas aussi souvent présents qu'ils pourraient l'être. Ce sont des tests qui exploitent les propriétés particulières des objets métier, et ces tests sont très courts et très faciles à mettre en œuvre. Il serait donc dommage de s'en priver. Cet article illustre ce type de tests, dont le point commun semble être un principe de vérification d'invariances.

II - Invariance d'un résultat de calcul

L'idée de ce principe me vient d'une application scientifique développée il y a quelques années. En gros l'application permettait d'ouvrir un fichier, de régler quelques paramètres, et d'appuyer sur un bouton qui lançait des calculs, et enfin des graphiques et des tableaux de données étaient mis à jour. C'est très représentatif de notre travail habituel. Dans ce cas de cette application précise, après quelque temps d'utilisation, un utilisateur s'est aperçu que chaque fois qu'il appuyait sur le bouton « Calculer » il obtenait des résultats différents ! Le résultat était juste la première fois, mais faux les fois suivantes. Nous n'avions pas détecté le problème nous-mêmes dans nos tests, car il ne nous était jamais venu à l'esprit d'appuyer plusieurs fois de suite sur le bouton.

Évidemment une fois identifié, le problème n'était pas trop difficile à comprendre : il était causé par une erreur d'initialisation cachée dans quelques milliers de lignes de code.

En fait à l'époque nous dépendions entièrement de tests manuels. Heureusement depuis nous avons beaucoup progressé en matière de développement dirigé par les tests et autres tests automatisés. Toutefois, si l'on n'y prend pas garde, rien n'empêche de faire exactement la même erreur en pratiquant le développement dirigé par les tests ! Le reste de cette section illustre concrètement ce risque, et comment y remédier simplement.

En simplifiant à l'extrême nos classes de calcul de l'époque, on arrive à quelque chose comme :

public class ClasseQuiImplementeUnCalculComplexe
{
private int etat_initial;

public ClasseQuiImplementeUnCalculComplexe (int valeur)
{
etat_initial = valeur;
}

public int Calcule(int parametre)
{
int resultat = etat_initial + parametre;
etat_initial = parametre; // ERREUR ICI
return resultat;
}
}

Il y a de grandes chances pour que vos tests unitaires ressemblent à celui-ci (généré automatiquement grâce au « Wizard » de Visual Studio) :

[TestMethod()]
public void CalculeTest()
{
int etat_initial = 100;
ClasseQuiImplementeUnCalculComplexe target = new ClasseQuiImplementeUnCalculComplexe(etat_initial);
int parametre = 10;
int valeur_attendue = 110;
int valeur_calculee = target.Calcule(parametre);
Assert.AreEqual(valeur_attendue , valeur_calculee);
}

Et malheureusement ce genre de test ne capture pas le problème d'initialisation que j'ai sournoisement introduit dans les lignes de code ci-dessus (des milliers dans la réalité). En fait il nous faut un test du type :

[TestMethod()]
public void CalculeDoitToujoursDonnerLeMemeResultat()
{
int etat_initial = 100;
ClasseQuiImplementeUnCalculComplexe target = new ClasseQuiImplementeUnCalculComplexe(etat_initial);
int parametre = 50;
Assert.AreEqual(target.Calcule(parametre), target.Calcule(parametre));
}

Dans ce deuxième test, j'ai volontairement évité de préciser la valeur attendue, afin d'attirer votre attention sur le fait que l'on peut programmer des tests très pertinents sans même connaître précisément la valeur attendue. Cela me paraît très important, car dans la vie réelle il n'est pas toujours simple de spécifier précisément une valeur attendue - il est alors très agréable de pouvoir réaliser des tests indirects comme celui ci-dessus.

III - Invariance d'une caractéristique

Cet exemple provient d'une autre application scientifique, dans laquelle un objet métier complexe devait subir une transformation particulière qui produisait deux nouveaux objets remplaçant le premier. L'algorithme de la transformation était relativement complexe et il n'était pas possible de prédire très précisément les nouvelles caractéristiques des objets obtenus - dans le sens où l'on pouvait facilement les déterminer à 10-2 près, mais pas à 10-8 près. Le test était manuel, et consistait à vérifier que des valeurs calculées correspondaient aux valeurs attendues à 10-2.

Pour illustrer notre propos, nous pouvons considérer que l'objet métier est un Rectangle, que l'on s'intéresse à sa Surface, et que la transformation consiste à le Partager en deux.

public class Rectangle
{
private readonly double largeur;
private readonly double longueur ;

public Rectangle (double largeur, double longueur)
{
this.longueur = longueur;
this.largeur = largeur;
}

public double Surface()
{
return longueur*largeur;
}

public List<Rectangle> Partage()
{
List <Rectangle> resultat = new List<Rectangle>();

// le calcul du milieu est intentionnellement faux
int milieu = (int) longueur/2;

resultat.Add(new Rectangle(largeur,  milieu));
resultat.Add(new Rectangle(largeur, milieu));
return resultat;
}

}

Dans notre cas réel il y avait une erreur subtile dans la transformation (elle est simulée ci-dessus par le calcul du milieu qui est faux, car réalisé avec une division entière). Elle est restée indétectée jusqu'à ce que quelqu'un ait l'idée d’appliquer cette transformation plusieurs fois de suite et constate alors une variation inattendue sur une caractéristique globale. En effet la surface totale de tous les objets se mettait à diminuer, alors qu'elle aurait dû rester strictement constante. Nous n'avions pas pensé à exploiter le fait que cette surface était invariante dans cette transformation, ce qui aurait été bien plus simple à vérifier manuellement que de vérifier des valeurs particulières à une certaine précision.

L'automatisation des tests, si l'on ne fait pas explicitement attention à exploiter une telle caractéristique invariante, peut tout à fait rencontrer ce genre de problème.

En effet, on risque de se limiter à des méthodes de test comme celle ci-dessous, qui est l'équivalent de notre test manuel initial dans lequel on se contentait d'une faible précision, car on ne connaissait pas exactement la valeur attendue :

[TestMethod()]
public void PartageTest()
{
const double FAIBLE_PRECISION = 1E-2;
double largeur = 10;
double longueur = 20.05;
double surface_attendue = 100;

Rectangle target = new Rectangle(largeur, longueur);
List<Rectangle> rectangles =  target.Partage();
Assert.AreEqual(surface_attendue, rectangles[0].Surface(), FAIBLE_PRECISION);
Assert.AreEqual(surface_attendue, rectangles[1].Surface(), FAIBLE_PRECISION);
}

Ce test réussit malgré l'erreur introduite dans la méthode Partage ! Par contre si l'on prend en compte l'invariance de la surface totale, on peut exiger une grande précision dans le test, car même sans connaître la valeur attendue on sait qu'elle doit être exactement la même que la valeur initiale :

[TestMethod()]
public void PartageDoitConserverLaSurfaceTotale()
{
const double HAUTE_PRECISION = 1E-8;
double largeur = 10;
double longueur = 20.05;

Rectangle target = new Rectangle(largeur, longueur);
List<Rectangle> rectangles = target.Partage();

Assert.AreEqual(target.Surface(), rectangles[0].Surface() + rectangles[1].Surface(), HAUTE_PRECISION);
}

Et à ce moment-là le test échoue et capture bien l'erreur dans Partage.

Remarquez à nouveau qu'il n'y a pas de valeur attendue dans ce dernier test.

IV - Invariance lors de la persistance

Dans nos applications nous sommes très souvent amenés à faire persister des objets dans un support externe au programme, typiquement des fichiers. Si on prend le point de vue de la vérification de l'invariance, la séquence d'opérations SauveDansFichier, LitDepuisFichier, SauveDansFichier doit produire toujours le même fichier. Si ce n'est pas le cas, il y a une erreur dans l'une de ces opérations. Par une erreur typique serait l'oubli de lire une propriété dans LitDepuisFichier, ou encore une erreur d'affectation (par exemple lire correctement longueur et largeur, mais affecter les deux valeurs au même champ. Voici un test qui pourrait capturer de tels problèmes :

[TestMethod()]
public void SauvegardeDoitConserverInformation()
{
Rectangle original = new Rectangle(10, 100);
original.SauveDansFichier("original.txt");

Rectangle copie = new Rectangle(0,0);
copie.LitDepuisFichier("original.txt");
copie.SauveDansFichier("copie.txt");

string original_texte;
string copie_texte;

using (StreamReader sr = File.OpenText( "original.txt"))
{
original_texte = sr.ReadToEnd();
}
using (StreamReader sr = File.OpenText("copie.txt"))
{
copie_texte = sr.ReadToEnd();
}
Assert.AreEqual(original_texte,copie_texte);
}

On retrouve à nouveau ce qui semble une propriété intéressante de ces tests sur l'invariance : il n'est pas nécessaire de contrôler le contenu précis du fichier généré pour écrire un test pertinent. Bien sûr ce test ne remplace pas les autres tests qui seraient nécessaires dans ce contexte, car il ne vérifie pas que l'on sauvegarde bien ce que l'on est supposé sauvegarder. Sa vocation est plutôt de compléter d'autres tests portant sur le contenu.

V - Invariance par inversion et autres opérations

Les idées développées ci-dessus s'appliquent en fait, dans un contexte de calcul scientifique, à toute opération disposant d'une opération inverse (au sens ou 1/X est l'inverse de X). Voici un exemple basique : supposons que vous ayez développé votre propre méthode pour calculer la racine carrée d'un nombre. En recherchant un test basé sur le principe de vérification de l'invariance, vous pourriez arriver à :

[TestMethod()]
public void InverseDoitConserverInformation()
{
double x = MaRacineCarree(2) ;
Assert.AreEqual(x*x, 2, 1E-8)
}

Si cet exemple peut paraître trop trivial, considérez alors celui-ci (dérivé également d'une application scientifique réaliste) :

[TestMethod()]
public void FusionDoitConserverLaSurfaceTotale()
{
const double HAUTE_PRECISION = 1E-8;
double largeur = 10;
double longueur = 20.05;

Rectangle target = new Rectangle(largeur, longueur);
List<Rectangle> rectangles = target.Partage();

Rectangle rectangle_fusion = rectangles[0].FusionneAvec(rectangles[1]);

Assert.AreEqual(target.Surface(), rectangle_fusion.Surface(), HAUTE_PRECISION);
}

Évidemment, l'opération FusionneAvec que nous venons d'introduire doit être symétrique :

[TestMethod()]
public void SymetrieDoitConserverLaSurfaceTotale()
{
const double HAUTE_PRECISION = 1E-8;
double largeur = 10;
double longueur = 20.05;

Rectangle target = new Rectangle(largeur, longueur);
List<Rectangle> rectangles = target.Partage();

Rectangle rectangle_fusion01 = rectangles[0].FusionneAvec(rectangles[1]);
Rectangle rectangle_fusion10 = rectangles[1].FusionneAvec(rectangles[0]);

Assert.AreEqual(rectangle_fusion01.Surface(), rectangle_fusion10.Surface(), HAUTE_PRECISION);
}

Ce qui montre que diverses autres opérations peuvent être utilisées pour ce type de test rotations, translations, etc.

Conclusion

Cet article décrit plusieurs exemples de tests unitaires qui exploitent un principe de vérification d'invariances. Il est intéressant de programmer ce genre de tests, car ils sont quasiment gratuits, dans le sens où il n'est même pas nécessaire de connaître précisément une valeur attendue. De plus ces tests peuvent capturer certaines erreurs (comme des erreurs d'initialisations) que d'autres tests pourraient laisser passer. Et plus généralement ces tests permettent d'améliorer le modèle métier, car ils incitent à réfléchir sur certaines propriétés intéressantes des objets métier.

Remerciements

Merci à Homo Agilis pour sa relecture de cet article, et à Ricky81 pour ses commentaires.