I. Les Tests unitaires▲
I-1. Introduction▲
Dans un projet agile, nous nous engageons à livrer une application sous forme de petite itération fonctionnelle de sorte que le client puisse approuver ses interactions au fur et à mesure. Le but est de s’adapter rapidement aux changements que pourrait apporter le client pendant l’avancement du projet, sans que cela puisse avoir de gros impact sur les fonctions déjà développées. Pour ce faire, à l’ajout d’une nouvelle itération, il faudrait s’assurer que le nouveau code ajouté ne soit pas une source de régression. Les tests unitaires sont utilisés dans ce sens afin de s’assurer de la fiabilité de chaque code ajouté au projet.
En effet, un test unitaire est un test écrit par le développeur et qui a pour but de tester une partie d’un code indépendamment des objets et composants dont il dépend. L’objectif étant d’isoler ce bout de code et de tester s’il fonctionne correctement. Le test va décrire les variables en entrée (objets, format de donnée) et s’assurer que le résultat en sortie est conforme à celui attendu. Le test doit retourner toujours le même résultat, ce qui est la preuve que le fonctionnement est correct.
Dans le processus de développement d’applications, il existe aussi d’autres types de tests en dehors des tests unitaires.
I-2. Les différents types de tests dans un projet de développement d’applications.▲
Les tests unitaires valident, de manière isolée, une petite partie du code de l’application. Il existe aussi les tests d’intégration qui vérifient si les différents composants de l’application communiquent correctement, les tests fonctionnels qui évaluent le fonctionnement de l’application conformément au fonctionnement prévu. On effectue aussi les tests graphiques pour vérifier l’interface utilisateur et les tests de sécurité pour éprouver tous les aspects liés à la sécurité de l’application. Tous ces tests jouent chacun leur partition pour livrer une application fiable, sécurisée, ergonomique et qui fonctionne correctement.
La pyramide de tests proposée par Mike Cohn dit que plus on évolue dans le niveau de tests, plus ceux-ci deviennent coûteux et moins on doit en faire.
I-3. Exemple de tests unitaires▲
Il existe plusieurs technologies permettant de faire des tests unitaires. En Java, nous avons JUnit, en Php PHPUnit et en .Net XUnit et NUnit. Tout au long de ce tutoriel, nous allons utiliser XUnit.
L’exemple : nous allons développer un programme qui effectue la somme des éléments d’un tableau d’entiers.
I-3-1. Création du projet dans Visual Studio▲
Nous allons créer un projet Console de type .net Core dans Visual Studio 2019. On va nommer ce projet MockTutorial.
À notre solution, nous allons ajouter un projet XUnit que nous allons nommer MockTutorial.Tests.
Au sein de notre projet MockTutorial.Tests, on n’oublie pas d’ajouter une référence à notre projet MockTutorial.
Revenons dans le projet MockTutorial où nous allons créer la classe de gestion des tableaux :
ArrayManagement.cs est la méthode qui fait la somme des entiers d’un tableau donné : SumOfItems().
public
class
ArrayManagement
{
public
int
SumOfItems
(
int
[]
items)
{
return
0
;
}
}
Parallèlement, nous créons dans notre projet MockTutorial.Tests la classe ArrayManagementTests.cs pour tester notre méthode qui fait la somme des entiers. Il faut noter que dans une classe de test xUnit, la méthode de tests est définie à l’aide de l’attribut [Fact].
public
class
ArrayManagementTests
{
[Fact]
public
void
GivenArrayOfIntegers_WhenCallingItemsSum_ThenReturnSumOfEachItems
(
)
{
}
}
I-3-2. L’approche TDD▲
TDD (Test-Driven Development) ou développements pilotés par les tests est une méthode de développement d’application qui préconise l’écriture des tests unitaires avant l’écriture du code. C’est donc le code qui sera écrit en fonction du résultat attendu des tests unitaires contrairement à la vieille approche où l’on écrivait le code et on testait ensuite le résultat.
Dans la pratique : on commence par décrire notre cas de tests et on vérifie si notre méthode retourne le résultat attendu. Dans cet exemple, on écrit un test qui reçoit un tableau d’entiers dont la somme fait 29 ; et on vérifie que notre méthode SumOfItems retourne bien 29, quand elle est appelée avec en paramètre ce tableau.
2.
3.
4.
5.
6.
7.
8.
9.
10.
11.
12.
13.
14.
15.
[Fact]
public
void
GivenArrayOfIntegers_WhenCallingSumOfItemsWithParameters_ThenReturnSumOfEachItems
(
)
{
//Arrange
var
items =
new
int
[
5
]
{
3
,
4
,
5
,
7
,
10
};
var
arrayManagement =
new
ArrayManagement
(
);
//Act
var
actualValue =
arrayManagement.
SumOfItems
(
items);
var
expectedValue =
29
;
//Assert
Assert.
Equal
(
expectedValue,
actualValue);
}
Puis on exécute le test pour s’assurer qu’il échoue. Le test retournera bien False parce que nous n’avons pas encore écrit dans notre méthode SumOfItems le code qui effectuera la somme des entiers du tableau.
On écrit maintenant le code pour faire passer notre test et on le réexécute. On constate alors que le test réussit cette fois !
2.
3.
4.
5.
6.
7.
8.
9.
10.
11.
12.
public
class
ArrayManagement
{
public
int
SumOfItems
(
int
[]
items)
{
var
value
=
0
;
foreach
(
var
item in
items)
{
value
+=
item;
}
return
value
;
}
}
On reprend la boucle si on a d’autres spécificités à ajouter à notre code.
Dans ce cas d’espèce, on peut créer une autre méthode de test pour s’assurer que la méthode SumOfItems retourne une exception de type ArgumentNullException lorsqu’elle reçoit un objet nul en paramètre.
[Fact]
public
void
GivenArrayOfIntegers_WhenCallingSumOfItemsWithNullParameters_ThenThrowArgumentNullException
(
)
{
//Arrange
var
arrayManagement =
new
ArrayManagement
(
);
var
expectedMessage =
"Value cannot be null."
;
//Act
var
exception =
Assert.
Throws<
ArgumentNullException>((
) =>
arrayManagement.
SumOfItems
(
null
));
//Assert
Assert.
Contains
(
expectedMessage,
exception.
Message);
}
On exécute le test, il échoue bien entendu. Alors on réadapte notre code pour faire passer ce test.
public
int
SumOfItems
(
int
[]
items)
{
if
(
items ==
null
)
{
throw
new
ArgumentNullException
(
);
}
var
value
=
0
;
foreach
(
var
item in
items)
{
value
+=
item;
}
return
value
;
}
Et voilà ! Nous voici devenus des experts en TDD !
I-3-3. La couverture de code▲
On ne peut pas parler de tests unitaires sans parler de couverture de code. Même si nous n’allons pas voir cette partie dans les détails, il nous faut expliquer ce concept. La couverture de code est une métrique qui définit le taux de code exécuté lorsqu’une série de tests unitaires est lancée.
Si un code contient une instruction « If » et une autre « Else », et qu’on écrit un code qui vérifie le comportement de la méthode dans le « If » alors on n’aura couvert que 50% de ce code. Si on veut couvrir 100% de ce code, il nous faut rajouter une méthode qui teste le comportement de celle-ci dans le « Else ».
On comprend alors que la couverture de code est utilisée pour analyser à quel point l’ensemble du code écrit est testé.
De nombreux outils nous permettent de déterminer la couverture de code et d’afficher le code non couvert. Dans Visual Studio 2019 Enterprise, il est possible d’analyser la couverture de code à travers le menu « Tests » et le sous-menu « Analyse Code Coverage for All Tests ».
II. Les Mocks▲
II-1. Introduction▲
Comme on l’a dit plus haut, un test unitaire doit valider de manière isolée une partie du code sans tenir compte de ses dépendances. Dans l’exemple précédent, cela se fait de manière aisée puisque notre méthode SumOfItems ne dépend d’aucune autre classe. Dans le cas où notre code à tester a une dépendance vers un objet inaccessible ou non implémenté, il nous faut utiliser un Mock. Le Mock est un objet qui permet de simuler le comportement attendu de l’objet dont on dépend afin de se concentrer sur l’objet que l’on souhaite tester.
II-2. Apprenons par l’exemple▲
Je suis conscient que cette définition peut paraître toujours un peu floue pour celui qui n’a jamais fait de Mock, mais nous allons apprendre un peu plus en enrichissant l’exemple précédent.
Dans l’exemple précédent, on avait une méthode SumOfItems qui recevait un tableau d’entiers en paramètres. Supposons que le tableau ne doive plus être passé comme un paramètre, mais qu’on doive plutôt le récupérer dans une base de données à partir d’une classe DatabaseManagement. La modélisation de tout ça est la suivante :
public
interface
IDatabaseManagement
{
bool
IsConnected
(
);
void
Connect
(
);
int
[]
GetItemsFromDatabase
(
);
}
2.
3.
4.
5.
6.
7.
8.
9.
10.
11.
12.
13.
14.
15.
16.
17.
18.
19.
20.
21.
22.
23.
24.
25.
public
class
DatabaseManagement :
IDatabaseManagement
{
public
int
[]
GetItemsFromDatabase
(
)
{
//if connection sucessfully then retrieve data from database
if
(
IsConnected
(
))
{
//return table of integers;
}
//else return null;
return
null
;
}
public
bool
IsConnected
(
)
{
// Code to check the connection to the database
return
true
;
}
public
void
Connect
(
)
{
// Code to connect to the database
}
}
On doit pouvoir tester notre classe ArrayManagement sans se soucier du comportement de la classe DatabaseManagement. Le corps de cette classe n’étant pas encore implémenté (peut-être qu’il existe encore des discussions sur le type de bases de données à utiliser dans notre application), cela ne doit pas nous freiner dans l’avancement du développement de notre classe ArrayManagement. Dans ce cas, pour tester notre classe ArrayManagement, nous allons « mocker » l’interface IDatabaseManagement afin de simuler le comportement attendu de la classe DataManagement.
Pour faire un Mock dans un projet XUnit, on peut utiliser le package Moq. Pour cela, nous allons l’installer à travers notre gestionnaire de package.
Install-
Package Moq -
Version 4
.
13
.
0
Une fois le package installé, réécrivons nos méthodes de tests :
1 er cas
2.
3.
4.
5.
6.
7.
8.
9.
10.
11.
12.
13.
14.
15.
[Fact]
public
void
GivenArrayOfIntegers_WhenCallingSumOfItems_ThenReturnSumOfEachItems
(
)
{
//Arrange
var
databaseManagement =
new
Mock<
IDatabaseManagement>(
);
databaseManagement.
Setup
(
x =>
x.
GetItemsFromDatabase
(
)).
Returns
(
new
int
[
5
]
{
3
,
4
,
5
,
7
,
10
}
);
var
arrayManagement =
new
ArrayManagement
(
databaseManagement.
Object);
var
expectedValue =
29
;
//Act
var
actualValue =
arrayManagement.
SumOfItems
(
);
//Assert
Assert.
Equal
(
expectedValue,
actualValue);
}
Explications :
On crée un objet Mock à partir de l’interface IDatabaseManagement. On simule ensuite le comportement de l’objet de la manière suivante :
- on s’attend à ce que l’objet databaseManagement retourne un tableau d’entiers lorsqu’il évoque la méthode GetItemsFromDatabase ;
- on appelle la méthode SumOfItems de l’objet arrayManagement ;
- on vérifie que cette méthode retourne bien 29 quand elle traite le tableau d’entiers.
Le mot-clef utilisé pour simuler le comportement d’une méthode d’un objet Mock est Setup.
2 e cas
2.
3.
4.
5.
6.
7.
8.
9.
10.
11.
12.
13.
14.
15.
16.
[Fact]
public
void
GivenArrayOfIntegers_WhenGettingArrayFromDatabaseThrowsArgumentNullException_ThenThrowArgumentNullException
(
)
{
//Arrange
var
databaseManagement =
new
Mock<
IDatabaseManagement>(
);
databaseManagement.
Setup
(
x =>
x.
GetItemsFromDatabase
(
)).
Throws
(
new
ArgumentNullException
(
));
var
arrayManagement =
new
ArrayManagement
(
databaseManagement.
Object);
var
expectedMessage =
"Value cannot be null."
;
//Act
var
exception =
Assert.
Throws<
ArgumentNullException>((
) =>
arrayManagement.
SumOfItems
(
));
//Assert
Assert.
Contains
(
expectedMessage,
exception.
Message);
}
Explications :
- on s’attend à ce que l’objet databaseManagement lève une exception de type ArgumentNullException lorsqu’il fait appel à la méthode GetItemsFromDatabase pour obtenir le tableau d’entiers ;
- on appelle la méthode SumOfItems de l’objet arrayManagement ;
-
on vérifie que cette méthode lève elle aussi une exception de type ArgumentNullException.
Évidemment, quand on exécute les tests unitaires, ils échouent puisqu’on n’a pas encore écrit le code pour prendre en compte les modifications ajoutées à ces tests.
On réajuste alors le code de notre classe ArrayManagement pour faire passer les tests.
Sélectionnez1.
2.
3.
4.
5.
6.
7.
8.
9.
10.
11.
12.
13.
14.
15.
16.
17.
18.
19.
20.
21.
22.public
class
ArrayManagement{
IDatabaseManagement _databaseManagement;
public
ArrayManagement
(
IDatabaseManagement databaseManagement){
_databaseManagement=
databaseManagement;
}
public
int
SumOfItems
(
){
var
items=
_databaseManagement.
GetItemsFromDatabase
(
);
if
(
items==
null
){
throw
new
ArgumentNullException
(
);
}
var
value
=
0
;
foreach
(
var
itemin
items){
value
+=
item;
}
return
value
;
}
}
Pour améliorer la qualité de notre méthode, on peut écrire un test qui vérifie que lorsqu’il n’est pas encore connecté, l’objet databaseManagement essaie de créer une connexion à la base de données avant de récupérer le tableau d’entiers.
Sélectionnez1.
2.
3.
4.
5.
6.
7.
8.
9.
10.
11.
12.
13.
14.[Fact]
public
void
GivenApplicationNotYetConnectedToDatabase_WhenCallingItems_ThenCreateConnectionToDatabase
(
){
//Arrange
var
databaseManagement=
new
Mock<
IDatabaseManagement>(
);
databaseManagement.
Setup
(
x=>
x.
IsConnected
(
)).
Returns
(
false
);
var
arrayManagement=
new
ArrayManagement
(
databaseManagement.
Object);
//Act
var
actualValue=
arrayManagement.
SumOfItems
(
);
//Assert
databaseManagement.
Verify
(
x=>
x.
Connect
(
),
Times.
Once);
}
Explications :
-
on s’attend à ce que l’objet databaseManagement retourne False lorsqu’on vérifie la connexion à la base de données ;
-
on appelle la méthode SumOfItems de notre objet arrayManagement ;
- on vérifie que durant l’exécution de cette méthode, notre objet databaseManagement fait un appel à sa méthode Connect afin de se connecter à la base de données.
L’exécution du test va bien sûr échouer une fois de plus, alors on modifie le code de notre méthode SumOfITems pour le faire passer.
2.
3.
4.
5.
6.
7.
8.
9.
10.
11.
12.
13.
14.
15.
16.
17.
18.
public
int
SumOfItems
(
)
{
if
(!
_databaseManagement.
IsConnected
(
))
{
_databaseManagement.
Connect
(
);
}
var
items =
_databaseManagement.
GetItemsFromDatabase
(
);
if
(
items ==
null
)
{
throw
new
ArgumentNullException
(
);
}
var
value
=
0
;
foreach
(
var
item in
items)
{
value
+=
item;
}
return
value
;
}
À travers ces exemples, nous voyons clairement plusieurs différents cas d’utilisation du package Moq de .Net.
III. Faire un Mock sur un fichier.▲
III-1. L’énoncé de l’exercice▲
Pour terminer notre tutoriel, nous allons voir un exemple de Mock un peu particulier, à savoir un Mock sur un fichier. En .Net, il existe la classe MockFileSystem appartenant au package System.IO.Abstractions.TestingHelpers. Cette classe implémente l’interface IFileSystem et nous permet de simuler un système de fichiers.
L’exercice : supposons que notre tableau d’entiers ne provienne pas d’une base de données, mais plutôt d’un fichier csv. On nous donne juste le chemin menant à ce fichier et nous devons retourner la somme des entiers contenus dans ce fichier. Les entiers sont séparés dans le fichier csv par une virgule «,».
Dans la méthode permettant d’effectuer la somme des entiers, nous devons dans un premier temps lire le contenu d’un fichier csv et le convertir en tableau. Pour cela, nous décidons de faire un Mock d’un fichier csv avec des entiers à l’intérieur. Ce fichier sera passé en paramètre à notre méthode de calcul de la somme des entiers.
III-2. Correction▲
En C#, il existe la classe File de System.IO qui nous permet de gérer(créer, lire, écrire, enregistrer, etc.) un fichier, mais grâce à la classe MockFileSystem, nous pouvons effectuer les opérations de cette classe File sans avoir besoin d’un vrai fichier physique.
Commençons par installer le package dans notre projet .net Core et le projet de tests associé :
Install-
Package System.
IO.
Abstractions.
TestingHelpers -
Version 7
.
0
.
4
Et réécrivons notre premier test unitaire :
[Fact]
public
void
GivenArrayOfIntegers_WhenCallingSumOfItems_ThenReturnSumOfEachItems
(
)
{
//Arrange
var
mockFileSystem =
new
MockFileSystem
(
);
MockFileData mockFileData =
new
MockFileData
(
"3,4,5,7,10"
);
string
path =
"C:/fichier.csv"
;
mockFileSystem.
AddFile
(
path,
mockFileData);
var
arrayManagement =
new
ArrayManagement
(
mockFileSystem);
//Act
var
actualValue =
arrayManagement.
SumOfItems
(
path);
var
expectedValue =
29
;
//Assert
Assert.
Equal
(
expectedValue,
actualValue);
}
Explications :
- on simule un système de fichiers qui contient un fichier csv à l’adresse : C:/fichier.csv et on définit le contenu de ce fichier. La somme des entiers contenus dans le fichier est égale à 29 ;
- on passe ce système de fichiers en paramètre au constructeur de la classe ArrayManagement et on appelle la méthode SumOfItems avec le chemin vers notre fichier csv ;
- on teste que cette méthode retourne bien 29.
On réajuste notre méthode pour faire passer notre test :
2.
3.
4.
5.
6.
7.
8.
9.
10.
11.
12.
13.
14.
15.
16.
17.
18.
19.
20.
21.
22.
23.
24.
25.
26.
27.
28.
29.
30.
31.
32.
33.
34.
35.
36.
public
class
ArrayManagement
{
IFileSystem _fileSystem;
public
ArrayManagement
(
IFileSystem fileSystem)
{
_fileSystem =
fileSystem;
}
private
int
[]
GetArrayFromFile
(
string
path)
{
var
content =
_fileSystem.
File.
ReadAllText
(
path);
var
contentArray =
content.
Split
(
","
);
var
items =
new
int
[
contentArray.
Length];
var
i =
0
;
foreach
(
var
item in
contentArray)
{
items[
i]
=
int
.
Parse
(
item.
ToString
(
));
i++;
}
return
items;
}
public
int
SumOfItems
(
string
path)
{
var
items =
GetArrayFromFile
(
path);
if
(
items ==
null
)
{
throw
new
ArgumentNullException
(
);
}
var
value
=
0
;
foreach
(
var
item in
items)
{
value
+=
item;
}
return
value
;
}
}
Je vous laisse ajouter d’autres tests unitaires qu’on pourrait écrire pour ce cas de figure et réajuster le code pour faire fonctionner ces tests.
Bah voilà, nous avons réussi à lire un fichier csv sans avoir eu besoin de créer un fichier physique. Il faut noter que dans le cas où nous voulons utiliser un vrai système de fichiers, il faut passer un objet de type FileSystem au constructeur de la classe ArrayManagement.
IV. Conclusion▲
Nous arrivons à la fin de notre tutoriel, j’espère que cela vous a permis de mieux assimiler les tests unitaires et les Mock. Au début cela peut nous sembler long et fastidieux, mais à coup sûr, on gagne en qualité en procédant ainsi.
V. Remerciements▲
Nous tenons à remercier escartefigue pour la relecture orthographique de ce tutoriel.