I. Introduction▲
I-A. Le pattern de test « objet simulacre » (Mock Object)▲
L'objectif des tests unitaires est de tester une méthode à la fois, en isolation du reste du système.
Un problème de façon récurrente, à savoir celui de tester du code qui dépend d'autres objets.
Comment faire pour tester une méthode qui utilise une base de données ?
Comment tester la validité d'une écriture dans un log ?
De façon générale, comment peut-on tester du code faisant intervenir un objet pour lequel l'initialisation est plus importante que le test que l'on pensait effectuer ?
C'est exactement pour résoudre ce genre de problème que l'on va utiliser le pattern de test « objet simulacre ».
Un « objet simulacre » est un objet qui va remplacer un des objets réels de notre solution, mais qui va retourner un résultat que l'on va prédéfinir. Non seulement ce pattern va nous permettre de tester notre logique de code en isolation, mais il va aussi permettre de valider les interactions entre objets. En effet, les frameworks de simulacres permettent de définir des attentes (expectations), qui devront être satisfaites pour que le test réussisse.
L'utilisation des simulacres a comme avantage quasi immédiat d'augmenter considérablement la couverture de tests. En effet, ils vont nous permettre de tester des niveaux et des objets normalement fastidieux à tester, en quelques lignes de code.
I-B. Quand utiliser des simulacres ?▲
On peut utiliser des simulacres dans les cas suivants :
- l'objet réel a un comportement non déterministe (il produit des résultats imprévisibles, comme une date ou la température actuelle) ;
- l'objet réel est difficile à mettre en place, ou est lent (cas d'une base de données, ou d'un service web) ;
- le comportement de l'objet réel est difficile à déclencher (par exemple, un problème de réseau) ;
- l'objet réel a (ou est) une interface utilisateur ;
- le test doit demander à l'objet la manière dont elle est utilisée (par exemple, un test peut avoir besoin de confirmer qu'une fonction a été effectivement appelée) ;
- l'objet réel n'existe pas encore (un problème courant lorsque l'on doit interagir avec d'autres équipes ou de nouveaux systèmes matériels).
Dans cet article, nous allons voir comment utiliser le pattern, d'abord en utilisant un objet créé à cet effet, puis à l'aide d'un framework de simulacres.
II. L'application▲
Je vais utiliser, comme fil rouge dans ce tutoriel, une petite application de gestion, basée sur la base de données Northwind Access. (Disponible ici).
Mon application va avoir l'architecture suivante :
- une application winform, utilisée comme couche de présentation ;
- une couche métier ;
- une couche d'accès aux données ;
- une couche Core, contenant les fonctionnalités de log.
Pour l'exemple, je vais développer une fenêtre permettant de visualiser les différents fournisseurs, et de les éditer.
Je vais, pour cela, utiliser uniquement des objets de base du framework. Dans un premier temps, je vais construire ma solution. Attention : bien que les simulacres puissent être utilisés en TDD, dans mon exemple, on va tester après le développement.
Je vais produire deux écrans, un qui affiche la liste des fournisseurs, avec un bouton qui va permettre de sélectionner un fournisseur, pour l'éditer, un bouton de création, un bouton de suppression, et un bouton pour quitter l'application.
Le second écran, lui, va comporter un champ pour chacun des champs de la base de données que je veux éditer, un bouton Annuler, et un bouton OK
Le projet, dans l'état initial (avec le code pour les insertions, mises à jour et sélection), est disponible ici.
Notre premier problème va être le suivant :
comment tester que le traitement de mes données fonctionne, alors qu'on ne maîtrise pas les performances de la base de données ?
En effet, une des principales caractéristiques de nos tests unitaires est qu'ils doivent être rapides et indépendants les uns des autres.
De plus, ils doivent avoir lieu dans un environnement maîtrisé.
Pour pouvoir contrôler l'environnement, nos choix vont donc être :
- soit de créer notre base de données à l'initialisation des tests, et de la détruire en fin de test, ce qui est coûteux en termes de temps ;
- soit de fournir « manuellement » les données à notre couche métier, en remplaçant l'objet fournisseur de données.
Attention, la première approche est une approche tests d'intégration sur base de données.
Je reviendrai probablement sur les tests d'intégration dans un prochain article, mais dans l'immédiat, on va utiliser nos simulacres pour exploiter la première approche.
III. Une première approche : un simulacre « manuel »▲
La première étape du tutoriel va être de créer notre simulacre de façon manuelle, en transpirant, « à l'ancienne ».
Pour cela, on va refactoriser le code, en ajoutant une interface pour notre couche d'accès aux données. De plus, je vais ajouter une propriété permettant de changer l'objet DataAccess utilisé par ma SuppliersFactory.
Pour l'exemple, je vais créer mon interface au niveau de SuppliersDataAccess, qui va hériter d'une interface ISupplierDataAccess. Dans la vraie vie, j'adopterais plus probablement une approche différente, avec une IDataBaseFactory, qui encapsulerait mes classes d'accès à la base de données au niveau en dessous… et j'utiliserais un framework d'injection de dépendances pour modifier le type de IDatabaseFactory utilisé à l'exécution.
III-A. Modifications dans la couche d'accès aux données▲
On va donc, dans un premier temps, ajouter une nouvelle interface ISuppliersDataAccess
using
System;
using
System.
Data;
using
System.
Data.
OleDb;
using
System.
Data.
Common;
using
Core;
namespace
DataAccess {
public
interface
ISuppliersDataAccess {
DataSet GetAllSuppliers
(
);
DataRow GetOneSuppliers
(
int
Supplierid);
bool
InsertSuppliers
(
string
Companyname,
string
Contactname,
string
Address,
string
City,
string
Country);
bool
UpdateSuppliers
(
int
Supplierid,
string
Companyname,
string
Contactname,
string
Address,
string
City,
string
Country);
bool
Exists
(
int
Supplierid);
}
}
Et ensuite, on va modifier le fichier SuppliersDataAccess pour faire hériter la classe de l'interface nouvellement créée.
namespace
DataAccess {
public
class
SuppliersDataAccess :
ISuppliersDataAccess {
……….
}
}
III-B. Modifications dans la couche métier▲
Au niveau de la couche métier, on va aussi modifier la classe SuppliersFactory qui, actuellement utilise directement SuppliersDataAccess.
Comme je vais faire mes modifications de dépendances à la main, et pour ne pas modifier le code de mon client, je vais ajouter une petite propriété, qui va me retourner le ISupplierDataAccess que je veux utiliser.
private
static
ISuppliersDataAccess _dataFactory;
public
static
ISuppliersDataAccess DataFactory {
get
{
if
(
_dataFactory ==
null
) _dataFactory =
new
SuppliersDataAccess
(
);
return
_dataFactory;
}
set
{
_dataFactory =
value
;
}
}
Ensuite, je vais remplacer mes appels à SuppliersDataAccess par des appels à DataFactory.
Par exemple :
public
static
bool
Update
(
int
Supplierid,
string
Companyname,
string
Contactname,
string
Address,
string
City,
string
Country) {
SuppliersDataAccess uda =
new
SuppliersDataAccess
(
);
return
uda.
UpdateSuppliers
(
Supplierid,
Companyname,
Contactname,
Address,
City,
Country);
}
Va devenir :
public
static
bool
Update
(
int
Supplierid,
string
Companyname,
string
Contactname,
string
Address,
string
City,
string
Country) {
return
DataFactory.
UpdateSuppliers
(
Supplierid,
Companyname,
Contactname,
Address,
City,
Country);
}
III-C. Création des tests▲
Je vais d'abord créer mon projet de tests, lui ajouter un répertoire lib, dans lequel je vais ajouter ma dll Nunit, et ajouter des références à mes projets Business et Data.
Enfin, je vais créer mon premier test.
Je ne vais pas tester directement SuppliersDataAccess, mais une nouvelle classe, mon premier simulacre, MockSuppliersDataAccess.
MockSuppliersDataAccess doit implémenter ISupplierDataAccess pour que je puisse l'utiliser. Mon test va être un test très basique, à savoir que je vais juste appeler la fonction GetAllSuppliers, et coder en dur un dataset que je vais renvoyer à ma factory.
Dans les faits, mon simulacre est donc plus un « bouchon » (stub) qu'un réel simulacre.
namespace
DataAccess {
public
class
MockSuppliersDataAccess :
ISuppliersDataAccess {
public
DataSet GetAllSuppliers
(
) {
DataTable dt =
new
DataTable
(
);
dt.
Columns.
Add
(
"SupplierID"
,
typeof
(
int
));
dt.
Columns.
Add
(
"CompanyName"
,
typeof
(
string
));
dt.
Columns.
Add
(
"ContactName"
,
typeof
(
string
));
dt.
Columns.
Add
(
"City"
,
typeof
(
string
));
dt.
Columns.
Add
(
"Country"
,
typeof
(
string
));
DataRow dr;
dr =
dt.
NewRow
(
);
dr[
"SupplierID"
]
=
1
;
dr[
"CompanyName"
]
=
"Fournisseur 1"
;
dr[
"ContactName"
]
=
"Contact 1"
;
dr[
"City"
]
=
"Ville 1"
;
dr[
"Country"
]
=
"Pays 1"
;
dt.
Rows.
Add
(
dr);
dr =
dt.
NewRow
(
);
dr[
"SupplierID"
]
=
2
;
dr[
"CompanyName"
]
=
"Fournisseur 2"
;
dr[
"ContactName"
]
=
"Contact 2"
;
dr[
"City"
]
=
"Ville 2"
;
dr[
"Country"
]
=
"Pays 2"
;
dt.
Rows.
Add
(
dr);
DataSet ds =
new
DataSet
(
);
ds.
Tables.
Add
(
dt);
return
ds;
}
}
}
Du côté des tests unitaires, je vais faire un appel assez standard à ma factory, mais je vais, juste avant cet appel, injecter mon MockSuppliersDataAccess à la place du DataAccess par défaut.
namespace
Test {
///
<
summary
>
/// Some generated Tests.
///
<
/summary
>
[TestFixture]
public
class
TestSuppliersFactory {
[SetUp]
public
void
Init
(
) {
}
[Test]
public
void
TestGetAllSupplier
(
) {
SuppliersFactory.
DataFactory =
new
MockSuppliersDataAccess
(
);
List<
Suppliers>
liste =
SuppliersFactory.
GetAllSuppliers
(
);
Assert.
AreEqual
(
liste.
Count,
2
);
Assert.
AreEqual
(
liste[
0
].
Supplierid,
1
);
Assert.
AreEqual
(
liste[
1
].
Supplierid,
2
);
}
}
}
Le projet, dans l'état actuel, est disponible ici.
Le problème majeur de cette approche est qu'une fonction ne pourra être testée qu'avec un seul type de retour. On ne pourra pas tester le cas ou ma fonction renvoie null, à moins de faire n classes, avec des retours de données différentes.
On va donc utiliser un framework mieux adapté à ce problème.
IV. IV. Utilisation du framework Rhino Mock▲
IV-A. Qu'est-ce que Rhino Mock ?▲
Rhino Mock est un framework de simulacres, développé par Oren Eini (http://www.ayende.com/).
Pour reprendre la description de la page d'accueil du site officiel, Rhino Mock est :
Un framework de simulacres dynamiques pour la plateforme .Net. Son but est de faciliter les tests, en permettant au développeur de créer des implémentations d'objets spécifiques et de vérifier les interactions en utilisant des tests unitaires.
Rhino Mock est un framework mature, en version 3.4 actuellement, bien documenté et utilisé par une partie non négligeable de la communauté .Net.
Oren Eini est un contributeur de projets open source tel que Castle, Nhibernate et Rhino, et son blog est une mine de bons conseils en termes de tests et de design (son statut de robot est activement discuté sur certaines listes, surtout depuis qu'il a dépassé les 3000 posts… en quatre ans le 1er avril 2008).
IV-B. Remplacement du test existant, et ajout des nouveaux tests▲
Dans un premier temps, je vais, sans pitié, effacer la classe MockSuppliersDataAccess, et mon test. Le fait d'utiliser un framework de simulacres dynamiques va me permettre de supprimer ce code de mes classes d'accès aux données.
La première de mes actions va être de revoir mon premier test, à l'intérêt assez limité… Mon test d'origine vérifiait que si je passais un Dataset à ma classe Factory, il me générait bien mes objets Suppliers. Le nouveau va vérifier que le comportement de la factory est adéquat si le DataAccess renvoie null ou pas de données.
Rhino nécessite impérativement de travailler sur des interfaces.
Dans notre exemple actuel, c'est déjà le cas, ce qui m'évite une refactorisation supplémentaire, mais si vous voulez pouvoir utiliser ce framework, n'oubliez surtout pas cette règle de base !!!
Mon nouveau test est le suivant :
[Test]
public
void
Test_GetAllSupplier_Returns_Empty_When_Null_Or_No_Data
(
) {
MockRepository mocks =
new
MockRepository
(
);
ISuppliersDataAccess da =
mocks.
CreateMock<
ISuppliersDataAccess>(
);
using
(
mocks.
Record
(
)) {
Expect
.
Call
(
da.
GetAllSuppliers
(
))
.
Return
(
new
DataSet
(
));
Expect
.
Call
(
da.
GetAllSuppliers
(
))
.
Return
(
null
);
}
SuppliersFactory.
DataFactory =
da;
using
(
mocks.
Playback
(
)) {
Assert.
AreEqual
(
SuppliersFactory.
GetAllSuppliers
(
).
Count,
0
);
Assert.
AreEqual
(
SuppliersFactory.
GetAllSuppliers
(
).
Count,
0
);
}
}
On va reprendre ce code pièce par pièce…
MockRepository mocks =
new
MockRepository
(
);
ISuppliersDataAccess da =
mocks.
CreateMock<
ISuppliersDataAccess>(
);
On initialise le conteneur de simulacres. Cette action est nécessaire pour pouvoir utiliser nos simulacres. Ensuite, on déclare un simulacre de ISuppliersDataAccess. Ce simulacre va avoir les mêmes caractéristiques qu'une implémentation standard d'un ISuppliersDataAccess.
using
(
mocks.
Record
(
)) {
Expect
.
Call
(
da.
GetAllSuppliers
(
))
.
Return
(
new
DataSet
(
));
Expect
.
Call
(
da.
GetAllSuppliers
(
))
.
Return
(
null
);
}
On initialise ensuite les attentes du conteneur.
Ces lignes informent le conteneur qu'il doit s'attendre à ce que da.GetAllSuppliers soit appelé deux fois de façon consécutive, et qu'il va retourner, la première fois, un nouveau dataset, et, la seconde fois, null
using
(
mocks.
Playback
(
)) {
Assert.
AreEqual
(
SuppliersFactory.
GetAllSuppliers
(
).
Count,
0
);
Assert.
AreEqual
(
SuppliersFactory.
GetAllSuppliers
(
).
Count,
0
);
}
On demande enfin au conteneur d'exécuter les actions SuppliersFactory.GetAllSuppliers en mode playback.
Ce mode va permettre de vérifier les attentes du conteneur. Si j'omets une des lignes, on aura une erreur à l'exécution du test, car le conteneur s'attend à deux appels à SuppliersFactory.GetAllSuppliers.
Mon premier test, réécrit, aura maintenant l'aspect suivant :
[Test]
public
void
Test_GetAllSupplier
(
) {
MockRepository mocks =
new
MockRepository
(
);
ISuppliersDataAccess da =
mocks.
CreateMock<
ISuppliersDataAccess>(
);
DataTable dt =
new
DataTable
(
);
dt.
Columns.
Add
(
"SupplierID"
,
typeof
(
int
));
dt.
Columns.
Add
(
"CompanyName"
,
typeof
(
string
));
dt.
Columns.
Add
(
"ContactName"
,
typeof
(
string
));
dt.
Columns.
Add
(
"City"
,
typeof
(
string
));
dt.
Columns.
Add
(
"Country"
,
typeof
(
string
));
dt.
Rows.
Add
(
new
Object[]
{
1
,
"Fournisseur 1"
,
"Contact 1"
,
"Ville 1"
,
"Pays 1"
}
);
dt.
Rows.
Add
(
new
Object[]
{
2
,
"Fournisseur 2"
,
"Contact 2"
,
"Ville 2"
,
"Pays 2"
}
);
DataSet ds =
new
DataSet
(
);
ds.
Tables.
Add
(
dt);
using
(
mocks.
Record
(
)) {
Expect
.
Call
(
da.
GetAllSuppliers
(
))
.
Return
(
ds);
}
SuppliersFactory.
DataFactory =
da;
using
(
mocks.
Playback
(
)) {
List<
Suppliers>
list =
SuppliersFactory.
GetAllSuppliers
(
);
Assert.
AreEqual
(
list.
Count,
2
);
Assert.
AreEqual
(
list[
0
].
Supplierid,
1
);
Assert.
AreEqual
(
list[
0
].
Address,
string
.
Empty);
Assert.
AreEqual
(
list[
0
].
Contactname,
"Contact 1"
);
Assert.
AreEqual
(
list[
0
].
City,
"Ville 1"
);
Assert.
AreEqual
(
list[
1
].
Supplierid,
2
);
Assert.
AreEqual
(
list[
1
].
Contactname,
string
.
Empty);
}
}
En faisant tourner le test la première fois, j'ai une erreur qui remonte. En effet, dans l'implémentation d'origine, mon constructeur de Suppliers n'appelle pas le constructeur par défaut, qui prend en charge l'initialisation. Address vaut donc null, ce qui fait échouer le test. Après une modification du constructeur qui prend un Datarow en paramètre, de façon à ce qu'il appelle le constructeur par défaut, mes tests fonctionnent !
Ces tests sont intéressants, mais ils ne démontrent qu'une seule des fonctionnalités du framework. En effet, tout ce que j'ai fait jusqu'à présent se résume à utiliser le framework comme fournisseur de bouchons, et pas réellement comme fournisseur de simulacres.
IV-C. Utilisation de simulacres comme bouchons dynamiques▲
Nos simulacres étant dynamiques, on va pouvoir s'en servir comme des bouchons dynamiques. C'est un peu l'approche que l'on a utilisée précédemment pour simuler l'accès à la base de données.
Un des cas, en dehors des bases de données, où on va utiliser ce style de test, est le cas où on a des objets longs à initialiser, ou nécessitant beaucoup de données à l'utilisation, et qui vont être utilisés pour fournir des données à d'autres fonctions.
Imaginons que l'on a une interface IPersonne, par exemple.
Cette interface définit tout ce que l'on doit connaître d'une personne dans notre application, et va potentiellement être assez fournie en accesseurs…
Par exemple, on va avoir :
public
Interface IPersonne
{
string
Nom {
get
;
}
string
Prenom {
get
;
}
string
Genre {
get
;
}
IAdresse AdresseDomicile {
get
;
}
IAdresse AdresseTravail {
get
;
}
DateTime DateDeNaissance {
get
;
}
ITelephone TelephoneDomicile {
get
;
}
ITelephone TelephoneMobile {
get
;
}
ITelephone TelephoneTravail {
get
;
}
// etc, etc.
}
Pour peu que l'on veuille tester une fonction dont le simple but est de formater quelques informations dans le but d'un publipostage, sous la forme de « Joyeux anniversaire, [Genre] [Prenom] [Nom] », un bouchon de test standard va nécessiter l'implémentation de TOUS les accesseurs de IPersonne…pour en tester trois. On va donc appeler notre framework à la rescousse, et écrire le test suivant :
[Test]
public
void
Test_Sujet_Publipostage
(
) {
MockRepository mocks =
new
MockRepository
(
);
IPersonne personne =
mocks.
CreateMock<
IPersonne>(
);
using
(
mocks.
Record
(
)) {
Expect
.
Call
(
personne.
Nom)
.
Return
(
"nom1"
).
Repeat.
Any
(
);
Expect
.
Call
(
personne.
Prenom)
.
Return
(
"prenom1"
).
Repeat.
Any
(
);
Expect
.
Call
(
personne.
Genre)
.
Return
(
"Mr"
).
Repeat.
Any
(
);
}
using
(
mocks.
Playback
(
)) {
Assert.
AreEqual
(
MailingFactory.
GetFormalSubject
(
personne),
"Joyeux anniversaire, Mr nom1 prenom1"
);
Assert.
AreEqual
(
MailingFactory.
GetCasualSubject
(
personne),
"Joyeux anniversaire, prenom1 nom1"
);
}
personne =
mocks.
CreateMock<
IPersonne>(
);
using
(
mocks.
Record
(
)) {
Expect
.
Call
(
personne.
Nom)
.
Return
(
null
).
Repeat.
Any
(
);
Expect
.
Call
(
personne.
Prenom)
.
Return
(
"prenom1"
).
Repeat.
Any
(
);
Expect
.
Call
(
personne.
Genre)
.
Return
(
null
).
Repeat.
Any
(
);
}
using
(
mocks.
Playback
(
)) {
Assert.
AreEqual
(
MailingFactory.
GetCasualSubject
(
personne),
"Joyeux anniversaire, prenom1"
);
Assert.
AreEqual
(
MailingFactory.
GetFormalSubject
(
personne),
"Joyeux anniversaire, prenom1"
);
}
}
Dans un premier temps, on a ajouté Repeat.Any(), de façon à ne pas avoir à faire trop d'allers-retours entre notre test et notre implémentation, vu que nous ne savons pas réellement, avant d'implémenter nos fonctions, le nombre d'accès que l'on va avoir à faire aux différentes variables (c'est aussi surtout, de ma part, par fainéantise, et parce que je ne suis pas la doctrine TDD à la lettre…). Après quelques itérations, j'arrive au code suivant :
public
static
string
GetFormalSubject
(
IPersonne personne) {
return
string
.
Format
(
"Joyeux anniversaire, {0}{1}{2}"
,
personne.
Genre !=
null
?
personne.
Genre +
" "
:
""
,
personne.
Nom !=
null
?
personne.
Nom +
" "
:
""
,
personne.
Prenom !=
null
?
personne.
Prenom +
" "
:
""
).
Trim
(
);
}
public
static
string
GetCasualSubject
(
IPersonne personne) {
return
string
.
Format
(
"Joyeux anniversaire, {0}{1}"
,
personne.
Prenom !=
null
?
personne.
Prenom +
" "
:
""
,
personne.
Nom !=
null
?
personne.
Nom +
" "
:
""
).
Trim
(
);
}
Et finalement, pour recoller au comportement de mon implémentation, je vais mettre à jour les attentes de mon test :
[Test]
public
void
Test_Sujet_Publipostage
(
) {
MockRepository mocks =
new
MockRepository
(
);
IPersonne personne =
mocks.
CreateMock<
IPersonne>(
);
using
(
mocks.
Record
(
)) {
Expect
.
Call
(
personne.
Nom)
.
Return
(
"nom1"
).
Repeat.
Times
(
4
);
Expect
.
Call
(
personne.
Prenom)
.
Return
(
"prenom1"
).
Repeat.
Times
(
4
);
Expect
.
Call
(
personne.
Genre)
.
Return
(
"Mr"
).
Repeat.
Twice
(
);
}
using
(
mocks.
Playback
(
)) {
Assert.
AreEqual
(
MailingFactory.
GetFormalSubject
(
personne),
"Joyeux anniversaire, Mr nom1 prenom1"
);
Assert.
AreEqual
(
MailingFactory.
GetCasualSubject
(
personne),
"Joyeux anniversaire, prenom1 nom1"
);
}
personne =
mocks.
CreateMock<
IPersonne>(
);
using
(
mocks.
Record
(
)) {
Expect
.
Call
(
personne.
Nom)
.
Return
(
null
).
Repeat.
Times
(
2
);
Expect
.
Call
(
personne.
Prenom)
.
Return
(
"prenom1"
).
Repeat.
Times
(
4
);
Expect
.
Call
(
personne.
Genre)
.
Return
(
null
);
}
using
(
mocks.
Playback
(
)) {
Assert.
AreEqual
(
MailingFactory.
GetCasualSubject
(
personne),
"Joyeux anniversaire, prenom1"
);
Assert.
AreEqual
(
MailingFactory.
GetFormalSubject
(
personne),
"Joyeux anniversaire, prenom1"
);
}
}
Comme ça, mon test n'est pas seulement un super bouchon dynamique, mais valide aussi l'interaction entre IPersonne et les fonctions de MailingFactory.
Le projet, dans l'état actuel, est disponible ici.
IV-D. Simulation de la classe de log (…ou pas)▲
Je veux maintenant pouvoir simuler ma classe de log, pour pouvoir vérifier que, dans le cas où je veux écrire un événement dans mon fichier de log depuis une de mes classes métier, un message est bien écrit dans le log, et qu'il correspond à ce que j'attends. Plutôt que de faire cette vérification directement dans le fichier de log résultant, je veux utiliser mon framework pour vérifier que la logique d'interaction est respectée.
Comme pour ma classe d'accès aux données, je veux ajouter une interface, puis un moyen d'injecter l'objet simulacre à la place de mon objet de log actuel…. Et là, c'est le drame… Comme je ne veux pas utiliser, dans cet article, de framework d'injection de dépendances, et que je ne veux pas reprendre toute l'implémentation pour de la classe de log (imaginons le cas d'une application que je dois faire évoluer, et pas d'un nouveau développement), je vais me retrouver coincé…
Coincé ??? Pas si sûr…
V. V. Utilisation du framework TypeMock Isolator▲
V-A. Qu'est-ce que TypeMock Isolator ?▲
TypeMock Isolator est un framework de simulacres dynamiques, tout comme Rhino.
Il est développé par une équipe, incluant Eli Lopian(http://www.elilopian.com/), qui a un blog assez intéressant sur les tests unitaires, ainsi que Roy Osherove (http://weblogs.asp.net/rosherove/default.aspx), qui vient de publier un livre sur les tests unitaires. TypeMock est un produit commercial, mais qui offre une version communautaire gratuite (qui est d'ailleurs celle sur laquelle on va se baser).
La version communautaire utilise des chaînes de caractères pour les appels aux fonctions et aux propriétés des classes simulées. Il est à noter que la version commerciale, elle, permet de faire le même genre d'appels que Rhino, ce qui permet de diminuer les risques qu'une refactorisation ne mette pas les tests en péril (les chaînes de caractères n'étant pas vérifiées par le compilateur…).
La description donnée sur la page d'accueil du site est la suivante :
- permet de tester le code qui était auparavant « untestable » ;
- réduit le temps consacré à l'écriture des tests unitaires, en vous donnant plus de temps pour vous concentrer sur vos tâches réelles ;
- permet de gagner du temps en éliminant la refactorisation et la réécriture du code juste pour le rendre testable ;
- encourage les développeurs à écrire des tests unitaires.Tout cela est GRATUIT !
L'idée de base de TypeMock, est d'utiliser la programmation orientée aspect pour rediriger les appels au code vers les parties simulées. Contrairement à un framework de simulacres standard, TypeMock ne nécessite pas d'utiliser un style de développement spécifique pour fonctionner. En fait, il permet tout simplement de se passer de framework d'injection de dépendances, et de ne pas avoir à surcharger chaque classe à tester par une interface.
Ceci est vrai SEULEMENT si la seule raison que l'on avait d'utiliser un framework de DI ou des interfaces était pour rendre le code testable. Mon expérience personnelle est que, si les tests unitaires sont la seule raison d'ajouter ces éléments, alors autant les enlever que de devoir maintenir un niveau d'indirection dont l'utilité finale est réduite. Le fait d'utiliser TypeMock ne doit pas non plus inciter à écrire du code non maintenable.
Le but, finalement, est d'arriver à équilibrer la lisibilité, la testabilité et la maintenabilité du code.
V-B. Modifier mes tests existants▲
Pour ne pas avoir à maintenir les deux frameworks, je vais déjà mettre à jour le code de mes tests existants, en utilisant TypeMock au lieu de Rhino.
public
void
Test_GetAllSupplier_Returns_Empty_When_Null_Or_No_Data
(
) {
MockRepository mocks =
new
MockRepository
(
);
ISuppliersDataAccess da =
mocks.
CreateMock<
ISuppliersDataAccess>(
);
using
(
mocks.
Record
(
)) {
Expect
.
Call
(
da.
GetAllSuppliers
(
))
.
Return
(
new
DataSet
(
));
Expect
.
Call
(
da.
GetAllSuppliers
(
))
.
Return
(
null
);
}
SuppliersFactory.
DataFactory =
da;
using
(
mocks.
Playback
(
)) {
Assert.
AreEqual
(
SuppliersFactory.
GetAllSuppliers
(
).
Count,
0
);
Assert.
AreEqual
(
SuppliersFactory.
GetAllSuppliers
(
).
Count,
0
);
}
}
Le test va devenir :
public
void
Test_GetAllSupplier_Returns_Empty_When_Null_Or_No_Data
(
)
{
MockManager.
Init
(
);
Mock mock =
MockManager.
MockAll<
SuppliersDataAccess>(
);
mock.
ExpectAndReturn
(
"GetAllSuppliers"
,
new
DataSet
(
));
mock.
ExpectAndReturn
(
"GetAllSuppliers"
,
null
);
Assert.
AreEqual
(
SuppliersFactory.
GetAllSuppliers
(
).
Count,
0
);
Assert.
AreEqual
(
SuppliersFactory.
GetAllSuppliers
(
).
Count,
0
);
MockManager.
Verify
(
);
}
Les premières et dernières lignes du code permettent d'initialiser et de vérifier le conteneur de simulacres. Je les ai mises ici pour l'exemple, mais en réalité, on les déplacera dans les appels aux fonctions SetUp et TearDown de Nunit. L'appel à Verify permet de vérifier que les attentes définies dans ExpectAndReturn sont remplies par notre code.
V-C. Simuler (enfin) la classe de log▲
Pour pouvoir avoir un test, je vais modifier la fenêtre d'affichage de ma liste de fournisseurs. Imaginons que je veuille que les appels à la routine qui liste les fournisseurs soient loggés…ainsi que les exceptions… Je vais modifier mon code dans ma fenêtre, pour lui donner la forme suivante :
List<
Suppliers>
suppliers;
try
{
LogFactory.
LogDebug
(
"Récupération de la liste des fournisseurs"
);
suppliers =
SuppliersFactory.
GetAllSuppliers
(
);
}
catch
(
Exception ex)
{
LogFactory.
LogDebug
(
"Erreur dans GetAllSuppliers : "
+
ex.
Message);
return
;
}
Je vais maintenant faire le test suivant :
[Test]
public
void
Test_Logs
(
)
{
Mock mockLog =
MockManager.
MockAll
(
typeof
(
LogFactory));
Mock mockFactory =
MockManager.
MockAll
(
typeof
(
SuppliersFactory));
mockFactory
.
ExpectAndThrow
(
"GetAllSuppliers"
,
new
Exception
(
"test"
));
mockLog
.
ExpectCall
(
"LogDebug"
)
.
Args
(
"Récupération de la liste des fournisseurs"
);
mockLog
.
ExpectCall
(
"LogDebug"
)
.
Args
(
"Erreur dans GetAllSuppliers : test"
);
FrmListeFournisseurs frm =
new
FrmListeFournisseurs
(
);
mockFactory
.
ExpectAndReturn
(
"GetAllSuppliers"
,
new
List<
Suppliers>(
));
mockLog
.
ExpectCall
(
"LogDebug"
)
.
Args
(
"Récupération de la liste des fournisseurs"
);
frm =
new
FrmListeFournisseurs
(
);
}
Le projet, dans l'état actuel, est disponible ici.
VI. VI. Autres frameworks de simulacres▲
Pour finir ce petit tour d'horizon, je vais vous présenter deux autres frameworks de simulacres.
VI-A. NMock▲
NMock est un peu l'ancêtre des autres frameworks de simulacres en .net.
En effet, c'est (à ma connaissance), non seulement un des plus vieux de ces frameworks encore actifs, mais en plus le premier qui ne soit pas un portage de framework depuis Java.
C'est un projet open source, dont la page d'accueil se trouve ici. La grosse différence entre Rhino et NMock est que NMock utilise la même approche que TypeMock, à savoir l'utilisation de chaînes pour l'appel de la fonction simulée. Par exemple, un des tests précédemment écrits (Test_GetAllSupplier_Returns_Empty_When_Null_Or_No_Data) se traduirait, en NMock, par :
[Test]
public
void
Test_GetAllSupplier_Returns_Empty_When_Null_Or_No_Data
(
) {
Mockery mocks =
new
Mockery (
);
ISuppliersDataAccess da =
mocks.
NewMock<
ISuppliersDataAccess>(
);
Expect
.
Once.
On
(
da)
.
Method
(
"GetAllSuppliers"
)
.
Will
(
Return.
Value
(
new
DataSet
(
)));
Expect
.
Once.
On
(
da)
.
Method
(
"GetAllSuppliers"
)
.
Will
(
Return.
Value
(
null
));
SuppliersFactory.
DataFactory =
da;
Assert.
AreEqual
(
SuppliersFactory.
GetAllSuppliers
(
).
Count,
0
);
Assert.
AreEqual
(
SuppliersFactory.
GetAllSuppliers
(
).
Count,
0
);
mocks.
VerifyAllExpectationsHaveBeenMet
(
);
}
VI-B. Moq▲
Moq est un nouveau framework de simulacres, qui se base sur les capacités du framework 3.5.
Il a été développé pour utiliser à son avantage LINQ et les expressions lambda, ce qui en fait, d'après ses concepteurs, le framework le plus productif, simple, et le plus facile à refactoriser. À noter, tout comme TypeMock, il peut indifféremment simuler des classes ou des interfaces.
De la même façon que Rhino, il doit par contre se baser sur une injection de code pour pouvoir fonctionner.
Pour l'exemple, voici la version Moq de Test_GetAllSupplier_Returns_Empty_When_Null_Or_No_Data :
[Test]
public
void
Test_GetAllSupplier_Returns_Empty_When_Null_Or_No_Data
(
) {
var
da =
new
Mock<
SuppliersDataAccess>(
);
da.
Expect
(
u =>
u.
GetAllSuppliers
(
)).
Returns
(
new
DataSet
(
));
da.
Expect
(
u =>
u.
GetAllSuppliers
(
)).
Returns
(
null
);
SuppliersFactory.
DataFactory =
da.
Object;
Assert.
AreEqual
(
SuppliersFactory.
GetAllSuppliers
(
).
Count,
0
);
Assert.
AreEqual
(
SuppliersFactory.
GetAllSuppliers
(
).
Count,
0
);
}
On retrouve une syntaxe plus « légère » à l'utilisation. En effet, les attentes du simulacre sont « embarquées » dans l'objet simulacre lui-même. C'est d'ailleurs pour cette raison que l'on doit passer à la Factory da.Object, et non pas da, l'objet da étant effectivement un adaptateur du simulacre de SuppliersDataAccess.
Pour plus d'informations, je vous redirige sur la page de démarrage rapide de Moq.
VII. VII. Aller plus loin▲
Je ne suis, personnellement, pas un gourou des tests unitaires ou du TDD, plutôt un amateur convaincu.
Pour ceux qui voudraient approfondir le sujet des tests unitaires, je leur conseille de lire les traductions et articles de Bruno Orsier sur developpez, en français.
Pour approfondir les simulacres, je vous conseille de…vous armer de patience, et de partir pour une longue quête qui vous amènera probablement sur un (voire plusieurs) champ de bataille de religion.
En effet, l'un dans l'autre, les frameworks sont assez récents (EasyMock, l'ancêtre, date de 2001 pour sa version Java), et les membres les plus actifs des différents projets sont souvent des « extrémistes » du développement objet, avec des avis bien tranchés…
Un bon point de départ, à mon humble avis, est de parcourir les docs des différents frameworks, en commençant par celle de Rhino, qui est la plus mature, et de pratiquer dès que possible.
VIII. VII. Conclusion▲
Les frameworks de simulacres permettent d'affiner la granularité des tests unitaires d'une application, en vérifiant que les objets réagissent convenablement dans un contexte très contrôlé.
Ils permettent très facilement de valider des interactions, et peuvent même aller, dans des cas extrêmes, jusqu'à permettre de commencer le développement d'une application sans base de données, ou sans certains composants, qui seront simulés dynamiquement.
Ceci dit, ils ne sont pas une panacée. En particulier, avant de simuler un objet, il est conseillé d'évaluer s'il ne serait pas plus bénéfique de refactoriser le code…
Par exemple, prenons le cas d'une fonction se basant sur la validation d'une date.
public
static
bool
IsAfternoon
(
){
return
DateTime.
Now.
Hour >
12
;
}
On pourrait imaginer mettre en place une interface IDateProvider, avec une fonction ou une propriété GetDate, qui retournerait, dans le cas réel, la date courante, et, dans le cas des tests, une date choisie par le développeur.
Il serait certainement plus judicieux, dans ce cas, de refactoriser ainsi la fonction.
public
static
bool
IsAfternoon
(
DateTime dateToTest){
return
dateToTest.
Hour >
12
;
}
Une autre erreur à ne pas faire est que, si nous avons deux objets A et B, et que nous simulons l'objet A pour tester l'objet B, on va effectivement tester l'objet B, et l'interaction entre A et B, mais pas notre objet A…
Pour terminer la conclusion, j'ai utilisé exclusivement Rhino pendant quelques mois, avant de basculer sur TypeMock récemment. En effet, dans l'environnement où je travaille, maintenir :
File.
WriteAllText
(
filename,
text);
reste plus facile que de maintenir :
using
(
TextWriter writer =
IoC.
Resolve<
IFileWriterFactory>(
).
Create
(
filename))
writer.
Write
(
text);