Visual T# (prononcé [tiː.ʃɑːp]) est un environnement de développement gratuit de tests unitaires intégré à Visual Studio™, mais peut s'utiliser indépendamment. Il comprend :
Sommaire
T# est un langage de programmation pour Microsoft .NET, compatible avec C# v2 (sauf en ce qui concerne le code non "managed"), et offre les avantages suivants par rapport aux solutions NUnit ou Visual Studio Team Test :
assert
pour traiter tous les cas.Voici un exemple d'un test minimal, écrit en T# :
testclass{test{ Calculatrice calc =newCalculatrice();runtestdoublesomme = calc.Ajouter( 1, 2 );assertsomme == 3; } }
Bonnes pratiques pour les tests unitaires
T# est complètement orienté bonnes pratiques.
Un test unitaire est toujours composé de trois parties :
La partie la plus importante étant Exécution, c'est pour elle que vous écrivez le test.
T# identifie clairement la partie Exécution en faisant commencer l'instruction
par le mot clé runtest. La préparation est donc naturellement
tout ce qui se trouve avant runtest, et la vérification après runtest.
La partie vérification s'assure que tous les effets (par exemple : retour de fonction, changements des paramètres, modification d'instance, de déclarations statiques, de fichiers, bases de données...) escompté lors de l'utilisation de la déclaration se sont bien effectués comme prévu.
T# n'offre qu'une seul mot clé pour cela : assert. Le message
est automatiquement généré à partir du code source. Ainsi, si le test précédent
échoue (si la fonction Ajouter est mal codée en retournant toujours
0), le test échouera avec le message d'erreur suivant : "Expected:
somme == 3 but was: 0 == 3". Il est ainsi très rapide de déterminer que la somme
vaut 0, mais que l'on s'attendait à 3. Cette façon de générer le message d'erreur
le rend plus proche du code source (donc plus facile de faire le lien avec le code
quand l'erreur apparait) et décompose les différentes valeurs impliquées (si plusieurs
variables étaient impliquées, on saurait alors la valeur de chacune des variables
et non pas de l'expression finale), rendant le déboguage beaucoup plus facile.
De plus, les conversion naturelles du langage de programmation sont utilisées (somme
est un double, 3 est un entier). Ainsi,pas de problème pour comparer
les deux contrairement à Visual Studio Team Test pour lequel vous devez écrire :
Assert.AreEqual( 3.0, somme ); pour faire la même vérification!
Le test étant du code, il peut lui aussi échouer. Contrairement aux concurrents,
T# sait exactement l'instruction qui teste réellement (runtest). Ainsi,
il est en mesure de déterminer si l'échec du test s'effectue avant ou après cette
instruction :
runtest, c'est que l'on n'arrive
pas à se placer dans le contexte voulu pour tester notre intention. Le test n'est
pas bon.runtest, c'est que le code
testé ne fait probablement pas la bonne chose. Le code testé n'est pas bon.T# a donc 4 états pour un test :
Afin de profiter de cette différence et de rendre le test très clair, utilisez des
assert avant l'instruction runtest :
testclass{test{ Produit prod =newProduit( "T#", 123 );runtestprod.Prix = 0;assertprod.Prix == 0; } }
Dans cet exemple, on veut rendre gratuit T#. Le test passe. Le code set
de la propriété Prix est donc correct. Vraiment? Mais si ni le constructeur,
ni le set de la propriété Prix ne sont codés... le test
passe!
Un bon test pour le changement du prix est donc :
testclass{test{ Produit prod =newProduit( "T#", 123 );assertprod.Prix != 0;runtestprod.Prix = 0;assertprod.Prix == 0; } }
Maintenant, ce cas est exclu. Si le constructeur n'initialise pas la propriété
Prix, le premier assert échouerait et, comme il est avant
l'instruction runtest, le test est 'Invalid' et non pas échoué! De
plus, d'un point de vue logique d'affaires, on voit bien que mettre le prix à 0
le fait passer d'une valeur non nulle à nulle et donc qu'un prix nul est acceptable.
T# incite à dire ce qui est testé, non pas par des noms de classes et méthodes les plus appropriés possibles, mais en l'indiquant clairement. Ainsi, le test précédent devrait s'écrire :
testclassforProduit {testPrixset{ Produit prod =newProduit( "T#", 123 );assertprod.Prix != 0;runtestprod.Prix = 0;assertprod.Prix == 0; } }
Les avantages sont les suivants :
Comme pour tout système de test, il y a beaucoup de redondances dans l'écriture des tests. En effet, il est nécessaire d'avoir plusieurs tests pour chaque déclaration d'une classe et, généralement, une classe possède plusieurs déclarations. Dans tous ces tests, il sera nécessaire de créer une instance de la classe à tester.
Tous les systèmes de test proposent une méthode à appeler avant tout test et une à appeler après tout test. T#, lui, ne propose qu'une seule méthode.
Cela procure ainsi les avantages suivants :
using, try...catch,
try...finally etc pour encadrer tout testLa forme la plus simple de contexte est le context de chaque test. C'est celle utilisée par défaut.
Les tests sont exécutés, mais pas directement. Le contexte, introduit par une méthode
déclarée par le mot clé testcontext, est appelée pour chaque
test. Le mot clé runtest indique l'endroit où le test
doit réellement s'exécuter.
Ainsi, dans notre exemple, nous voulons créer l'instance avec une seule ligne de code, mais il faut créer une instance pour chaque test :
testclassforProduit { Produit prod;testcontext{ prod =newProduit("T#", 123);runtest; }testPrixset// valeur minimale{assertprod.Prix != 0;runtestprod.Prix = 0;assertprod.Prix == 0; }testPrixset// valeur valide quelconque{assertprod.Prix != 12;runtestprod.Prix = 12;assertprod.Prix == 12; } }
Différents niveaux de contexte
En T#, le contexte se situe à trois niveaux :
test : le code du contexte est effectué pour chaque test. Le
test lui-même étant effectué à l'appel de runtest dans le contexte. Contexte par
défaut.testclass : le code du contexte est effectué pour la classe
de test. Les tests de la classe de test étant effectués à l'appel de runtest dans
le contexte.testassembly : le code du contexte est effectué pour l'ensemble
des classes de test de l'assemblage. Les tests étant effectué à l'appel de runtest
dans le contexte.Dans cet exemple, les tests seront exécutés 2 fois, sans avoir à les écrire 2 fois :
testclass{ IDbConnection connexion;testcontext{testclass{ connexion =newSQLConnection(...);runtest; connexion =newOracleConnection(...);runtest; } } ... }
Lors de l'écriture de tests unitaires, le problème le plus classique est : "Quel cas dois-je tester ?". En effet, une même déclaration doit-être testée dans différents cas. Un des exemples précédents traitait du prix d'un produit représenté par une propriété. Combien faut-il de tests et quels sont ces tests dans un tel cas?
Dans les systèmes de tests classiques, c'est encore une fois le nom du test qui dit quel cas est testé (ou un commentaire, comme dans notre exemple précédent). Cela donne des noms souvent très longs et pas nécessairement clairs... ni mis à jour.
T# introduit un nouveau mot clé pour exprimer le cas testé : when
suivi d'un cas à tester. Ainsi, l'exemple des tests du prix d'un produit devrait
être :
testclassforProduit { Produit prod;testcontext{ prod =newProduit( "T#", 123 );runtest; }testPrixsetwhenMinIncluded.IsMin {assertprod.Prix != 0;runtestprod.Prix = 0;assertprod.Prix == 0; }testPrixsetwhenMinIncluded.IsAboveMin {assertprod.Prix != 12;runtestprod.Prix = 12;assertprod.Prix == 12; } }
En fait, ce qui suit le mot clé when est un cas parmi plusieurs fonctionnant
ensemble, décrits par un critère. Dans notre exemple, le critère est MinIncluded
qui combine 2 cas normaux (IsAboveMin et IsMin) et 1 cas
d'erreur (BelowMinCase).
Il suffit donc d'identifier qu'un prix de produit a une valeur minimale (0) pour
identifier qu'il faut tester selon le critère MinIncluded. Ce critère
définissant 3 cas, il va falloir écrire 3 tests pour tester cette propriété, un
pour chaque cas défini dans le critère.
Pour l'instant, nous n'avons que deux cas définis (les cas normaux). Dès la compilation,
T# indique les cas manquants : MinIncludedCriteria.BelowMinCase.
En réalité, après un when, une expression de cas est utilisée. Cette
expression peut être un cas simple d'un critère ou une combinaison de critères.
Les opérateurs suivants existent :
&& (et logique) : combine toutes les possibilités
entre deux critères, sachant que seules les cas normaux sont combinables, les cas
d'erreur devant être testés séparément.|| (ou logique) : regroupe deux cas dans un même test.
À priori ce n'est pas une bonne idée, mais cela peut être nécessaire d'exprimer
la situation d'un même test exécutant plusieurs tests avec paramètres.=> (implication) : combine le cas de gauche avec les
différents cas du critère de droite. Utile lorsque toutes les combinaisons ne sont
pas logiques.
Enfin, lorsqu'un cas n'a pas de sens, il est possible de demander à ne pas le prendre
en compte en déclarant le cas !when. Dans ce cas, le test ne
doit pas avoir de code, donc pas d'accolades, seulement un point-virgule.
Il existe déjà beaucoup de critères dans la bibliothèque de T#, mais cela ne peut couvrir tout vos besoins. Il est alors très facile de créer les vôtres.
Un critère est comme un type énuméré, mais défini par le mot clé criteria
et non pas enum. Les cas d'erreur sont repérés en ajoutant l'attribut [Error]
au cas en question.
La convention veut que :
Ainsi, la déclaration de MinIncludedCriteria est la suivante :
publiccriteriaMinIncludedCriteria { [Error] BelowMinCase, IsMin, IsAboveMin, }
Comme nous l'avons vu avec les critères dans le paragraphe précédent, il est nécessaire de non seulement tester les cas normaux, mais aussi les cas d'erreur.
Généralement, un cas d'erreur est rapporté par une exception.
Il faut donc pouvoir tester les exceptions.
Tester qu'une exception est lancée
T# vérifie les exceptions comme toute autre vérification :
thrown et du nom de l'exceptionLes avantages sont les suivants :
Ainsi, dans l'exemple précédent, il est nécessaire de tester le cas où le prix affecté
au produit est négatif. Comme cela n'a pas de sens, la propriété devrait générer
une exception ArgumentOutOfRangeException. Testez-le ainsi :
testclassforProduit { Produit prod;testcontext{ prod =newProduit( "T#", 123 );runtest; }testPrixsetwhenMinIncluded.BelowMinCase {runtestprod.Prix = -12;assertthrownArgumentOutOfRangeException;assertprod.Prix == 123;// Le prix n'a pas changé} ... }
Tester complétement une exception
En fait, cela va simplement vérifier que l'exception est bien générée dans l'instruction runtest. Ce qui est déjà bien. Cependant, il serait bon de pouvoir valider le message d'erreur par exemple.
L'instruction assert thrown <type-exception> peut aussi être
suivi d'un nom de variable, comme dans une instruction catch, et d'un
bloc de code pour faire autant de vérifications voulues lorsque l'exception est
déclenchée. Accédez alors normalement à cette variable pour vérifier tout ce que
vous voulez.
testclassforProduit { Produit prod;testcontext{ prod =newProduit( "T#", 123 );runtest; }testPrixsetwhenMinIncluded.BelowMinCase {runtestprod.Prix = -12;assertthrownArgumentOutOfRangeException e {asserte.Message == "Un prix ne peut être négatif!"; }assertprod.Prix == 123;// Le prix n'a pas changé} ... }
Le problème d'utiliser des contextes est que celui-ci peut se trouver physiquement loin du test que l'on travaille, et lorsqu'il est changé, peut avoir des conséquences sur l'ensemble des tests.
Ainsi, dans l'exemple précédent, si le produit créé a maintenant un prix de 100
au lieu de 123, l'instruction assert prod.Prix == 123; échoue car le
prix sera de 100!
L'idéal serait de faire des tests relatifs : conserver la valeur initiale de
prod.Prix dans une variable locale, puis l'utiliser dans la vérification.
Le problème est que cela fait plus de code à écrire.
T# offre la possibilité d'écrire des vérifications relatives en une seule ligne de code.
Vérifier la constance d'une expression
La forme la plus simple de vérification relative est celle de la constance d'une expression.
T# offre une nouvelle forme de l'instruction assert : assert !changed
<expression>
L'expression sera évalué avant l'instruction runtest, et sa valeur
conservée, pour être de comparée par égalité au moment du assert en
question.
Ainsi, dans notre exemple, plutôt que de vérifier que le prix du produit est bien 123, il serait préférable de vérifier que le prix n'a pas changé :
testclassforProduit { Produit prod;testcontext{ prod =newProduit( "T#", 123 );runtest; }testPrixsetwhenMinIncluded.BelowMinCase {runtestprod.Prix = -12;assertthrownArgumentOutOfRangeException e {asserte.Message == "Un prix ne peut être négatif!"; }assert!changedprod.Prix; } ... }
Vérifier la constance d'un objet
La forme la plus sophistiquée de vérification relative est celle de la constance d'un objet. En effet, qui dit que notre code d'affaires n'a pas modifié l'objet avant que de lancer l'exception?
Dans l'instruction assert !changed <expression>, l'expression
peut référencer un objet et se terminer par :
.* : indique au compilateur T# de vérifier chacune des propriétés
publique de l'objet en question. Donc que l'objet n'a pas changé en apparence..-* : indique au compilateur T# de vérifier chacune des variables
(quel que soit le niveau d'encapsulation) de l'objet en question. Donc que l'objet
n'a vraiment pas changé.
Note : l'opérateur .- est semblable à l'opérateur .
si ce n'est qu'il accède à n'importe quelle déclaration, privée ou non.
Ainsi, dans notre exemple, plutôt que de vérifier que le prix du produit n'a pas
changé, il serait préférable de vérifier que l'objet prod n'a pas changé :
testclassforProduit { Produit prod;testcontext{ prod =newProduit( "T#", 123 );runtest; }testPrixsetwhenMinIncluded.BelowMinCase {runtestprod.Prix = -12;assertthrownArgumentOutOfRangeException e {asserte.Message == "Un prix ne peut être négatif!"; }assert!changedprod.-*;// le produit n'a pas changé} ... }
Sur le même principe, vérifiez qu'un changement a été apporté par une assertion relative.
Une assertion relative de changement s'effectue avec l'instruction assert
changed <affectation>.
L'affectation se présente sous 3 formes :
élément = expressionélément op= expression : ainsi, élément += 1 est
équivalent à élément = élément + 1élément++ ou element-- : ainsi, élément++
est équivalent à élément += 1 donc à élément = élément + 1
La partie de droite est évaluée avant l'instruction runtest, et conservée, pour
être comparée par égalité à la partie de gauche sur le assert correspondant.
Ainsi, assert changed élément++ n'incrémente pas élément, mais vérifie
que la valeur d'élément ajoutée de 1 avant l'instruction runtest est
égale à la valeur d'élément après l'instruction runtest. Ou tout simplement,
que l'instruction runtest à bien fait augmenter de 1 la valeur d'élément. Ainsi,
c'est l'expression équivalente à une affectation comme précisé, mais seulement des
accès en lecture sont effectués. Il est donc possible de l'utiliser avec des propriétés
en lecture seule.
Si nous reprenons notre exemple avec notre classe Produit, on pourrait
ajouter une classe Inventaire (une collection de Produit)qui
aurait donc une méthode Add(Produit) et une propriété Count.
Le test de cette méthode serait :
testclassforInventaire { Inventaire inventaire;testcontext{ inventaire =newInventaire();runtest; }testAdd( Produit p ) { Product produit =newProduct( "T#", 123 );runtestinventaire.Add( produit );assertchangedinventaire.Count++;// un produit a été ajoutéassertinventaire[ inventaire.Count - 1 ] == produit;// le produit a été ajouté en dernier} ... }
En dehors des exceptions, les événements non plus ne sont pas faciles à tester correctement. Il n'existe aucune facilité fournie par les système de tests existants.
T# offre une nouvelle fois l'instruction assert mais avec le mot clé
raised.
Par exemple, une classe implémentant INotifyPropertyChanged doit déclencher
l'événement PropertyChanged si une propriété est changée. Mais, elle
ne devrait pas déclencher l'événement si la valeur affectée est la même que
celle actuelle!
Note : Ce cas étant classique, nous T# fournit déjà le critère NotifyPropertyChangedCriteria
avec 3 cas :
Vérifier le non-déclenchement d'un événement
La forme la plus simple est la vérification du non déclenchement d'un événement.
En T#, la vérification du non-déclenchement d'un événement s'effectue comme toujours
en une ligne de code : assert !raised <événement>;
Le compilateur T# génère une variable d'instance et une méthode compatible avec
la signature de l'événement. Dans le test, la variable est initialisée à faux, le
méthode est enregistrée (+=) auprès de l'événement avant l'instruction
runtest et désenregistrée (-=) après l'instruction
runtest. La méthode générée va réinitialiser la variable à vrai. L'instruction
runtest !raised va vérifier que la variable est toujours à faux.
En supposant que notre classe Produit supporte l'interface INotifyPropertyChanged,
nous devrions avoir le test suivant :
testclassforProduit { Produit prod;testcontext{ prod =newProduit( "T#", 123 );runtest; }testPrixsetwhenMinIncluded.IsAboveMin && NotifyPropertyChanged.HasSubscribersSetSameValue {runtestprod.Prix = prod.Prix;assert!changedprod.-*;assert!raisedprod.PropertyChanged; } ... }
Vérifier le déclenchement d'un événement
La forme la plus simple de vérification du déclenchement d'un événement vérifie seulement que l'événement est déclenché.
Comme toujours, T# le vérifie en une seule ligne de code : assert raised
<événement>;
Le compilateur T# génère exactement les mêmes choses que pour assert !changed,
mais vérifie que la variable est à vrai.
Ainsi, dans notre exemple, nous devrions avoir :
testclassforProduit { Produit prod;testcontext{ prod =newProduit( "T#", 123 );runtest; }testPrixsetwhenMinIncluded.IsAboveMin && NotifyPropertyChanged.HasSubscribersSetOtherValue {assertprod.Prix != 12;runtestprod.Prix = 12;assertprod.Prix == 12;assertraisedprod.PropertyChanged; } ... }
Vérifier complétement un événement
L'inconvénient de procéder comme dans le chapitre précédent, c'est que cela prouve simplement que l'événement s'est déclenché, pas que :
sender est-il bien le produit modifié? Le second paramètre fait-il bien référence
à la bonne propriété?
Une forme beaucoup plus sophistiquée de test des événement existe :
assert raised <événement>( <paramètres> ) { <vérifications> }
Où :
Ainsi, le même tests que dans le chapitre précédent, mais complet serait :
testclassforProduit { Produit prod;testcontext{ prod =newProduit( "T#", 123 );runtest; }testPrixsetwhenMinIncluded.IsAboveMin && NotifyPropertyChanged.HasSubscribersSetOtherValue {assertprod.Prix != 12;runtestprod.Prix = 12;assertprod.Prix == 12;assertraisedprod.PropertyChanged(objectsender, PropertyChangedEventArgs e ) {assertsender == prod;asserte.PropertyName == "Prix";assertprod.Price == 12; } } ... }
Tester avec les 'Code Snippets'
Visual Studio offre la possibilité d'utiliser des 'Code Snippets' dans les langages de Microsoft. Nous avons aussi ajouté des 'Code Snippets' pour Visual T# (25 dans la version 1.0).
Ainsi, rien de plus facile pour générer vos tests :
Dans notre exemple, nous voulons tester le prix d'un produit.
Nous avons déjà établi que les critères à utiliser sont :
MinIncludedCriteria : car le prix peut être nul (valeur minimale),
mais pas négatif.NotifyPropertyChangedCriteria : car la classe Produit implémente
l'interface INotifyPropertyChanged.
Comme par hasard, il existe le 'Code Snippet' NotifyMinIncluded qui
va générer d'un seul coup le code.
Il ne reste plus qu'à indiquer les informations suivantes :
Et voici le code généré :
test Prix set
when MinIncluded.IsMin && NotifyPropertyChanged.HasNoSubscriber
{
assert prod.Prix != 0;
runtest prod.Prix = 0;
assert prod.Prix == 0;
}
test Prix set
when MinIncluded.IsMin && NotifyPropertyChanged.HasSubscribersSetSameValue
{
assert prod.Prix == 0;
runtest prod.Prix = 0;
assert !changed prod.Prix;
assert !raised prod.PropertyChanged;
}
test Prix set
when MinIncluded.IsMin && NotifyPropertyChanged.HasSubscribersSetOtherValue
{
assert prod.Prix != 0;
runtest prod.Prix = 0;
assert prod.Prix == 0;
assert raised prod.PropertyChanged( object sender, PropertyChangedEventArgs args )
{
assert sender == prod;
assert args.PropertyName == "Prix";
assert prod.Prix == 0;
}
}
test Prix set
when MinIncluded.IsAboveMin && NotifyPropertyChanged.HasNoSubscriber
{
assert prod.Prix != 12;
runtest prod.Prix = 12;
assert prod.Prix == 12;
}
test Prix set
when MinIncluded.IsAboveMin && NotifyPropertyChanged.HasSubscribersSetSameValue
{
assert prod.Prix == 12;
runtest prod.Prix = 12;
assert !changed prod.Prix;
assert !raised prod.PropertyChanged;
}
test Prix set
when MinIncluded.IsAboveMin && NotifyPropertyChanged.HasSubscribersSetOtherValue
{
assert prod.Prix != 12;
runtest prod.Prix = 12;
assert prod.Prix == 12;
assert raised prod.PropertyChanged( object sender, PropertyChangedEventArgs args )
{
assert sender == prod;
assert args.PropertyName == "Prix";
assert prod.Prix == 12;
}
}
test Prix set
when MinIncluded.BelowMinCase
{
runtest prod.Prix = -12;
assert thrown ArgumentOutOfRangeException;
assert !changed prod.-*;
}