Apprendre à faire des tests unitaires en .Net Core avec XUNIT

SORO Nacoumblé Olivier Martial

Vous entendez parler des tests unitaires, des Mocks mais vous ne savez pas concrètement comment et quand les appliquer ? Ce tutoriel a pour but de vous faire comprendre ces différents concepts et de vous montrer des cas pratiques d’utilisation à travers un mini projet .Net Core.

Prérequis : niveau débutant en C# et Visual Studio (2019 de préférence).

Pour réagir au contenu de ce tutoriel, un espace de dialogue vous est proposé sur le forum. Commentez

- Titre : Apprendre à faire des tests unitaires en .Net Core avec XUNIT

- Auteur : SORO Nacoumblé Olivier Martial

- Parution : 20 octobre 2019

- 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 ® 2019 oliviersoro. 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. Les Tests unitaires

- I-1. Introduction

- I-2. Les différents types de tests dans un projet de développement d’applications.

- I-3. Exemple de tests unitaires

- I-3-1. Création du projet dans Visual Studio

- I-3-2. L’approche TDD

- I-3-3. La couverture de code

- II. Les Mocks

- II-1. Introduction

- II-2. Apprenons par l’exemple

- III. Faire un Mock sur un fichier.

- III-1. L’énoncé de l’exercice

- III-2. Correction

- IV. Conclusion

- V. Remerciements

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.

Pyramide de tests

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.

1. [Fact]

2. public void GivenArrayOfIntegers_WhenCallingSumOfItemsWithParameters_ThenReturnSumOfEachItems()

3. {

4. //Arrange

5. var items = new int[5] { 3, 4, 5, 7, 10 };

6. var arrayManagement = new ArrayManagement();

8. //Act

9. var actualValue = arrayManagement.SumOfItems(items);

10. var expectedValue = 29;

12. //Assert

13. Assert.Equal(expectedValue, actualValue);

15. }

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 !

1. public class ArrayManagement

2. {

3. public int SumOfItems(int[] items)

4. {

5. var value = 0;

6. foreach (var item in items)

7. {

8. value += item;

9. }

10. return value;

11. }

12. }

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();
}

1. public class DatabaseManagement : IDatabaseManagement

2. {

3. public int[] GetItemsFromDatabase()

4. {

5. //if connection sucessfully then retrieve data from database

6. if (IsConnected())

7. {

8. //return table of integers;

9. }

10. //else return null;

11. return null;

12. }

14. public bool IsConnected()

15. {

16. // Code to check the connection to the database

17. return true;

18. }

20. public void Connect()

21. {

22. // Code to connect to the database

23. }

25. }

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 :

1er cas

1. [Fact]

2. public void GivenArrayOfIntegers_WhenCallingSumOfItems_ThenReturnSumOfEachItems()

3. {

4. //Arrange

5. var databaseManagement = new Mock<IDatabaseManagement>();

6. databaseManagement.Setup(x => x.GetItemsFromDatabase()).Returns(new int[5] { 3, 4, 5, 7, 10 });

7. var arrayManagement = new ArrayManagement(databaseManagement.Object);

8. var expectedValue = 29;

10. //Act

11. var actualValue = arrayManagement.SumOfItems();

13. //Assert

14. Assert.Equal(expectedValue, actualValue);

15. }

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.

2e cas

1. [Fact]

2. public void GivenArrayOfIntegers_WhenGettingArrayFromDatabaseThrowsArgumentNullException_ThenThrowArgumentNullException()

3. {

4. //Arrange

5. var databaseManagement = new Mock<IDatabaseManagement>();

6. databaseManagement.Setup(x => x.GetItemsFromDatabase()).Throws(new ArgumentNullException());

7. var arrayManagement = new ArrayManagement(databaseManagement.Object);

8. var expectedMessage = "Value cannot be null.";

10. //Act

11. var exception = Assert.Throws<ArgumentNullException>(() =>

12. arrayManagement.SumOfItems());

14. //Assert

15. Assert.Contains(expectedMessage, exception.Message);

16. }

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.

1. public class ArrayManagement

2. {

3. IDatabaseManagement _databaseManagement;

4. public ArrayManagement(IDatabaseManagement databaseManagement)

5. {

6. _databaseManagement = databaseManagement;

7. }

8. public int SumOfItems()

9. {

10. var items = _databaseManagement.GetItemsFromDatabase();

11. if(items == null)

12. {

13. throw new ArgumentNullException();

14. }

15. var value = 0;

16. foreach (var item in items)

17. {

18. value += item;

19. }

20. return value;

21. }

22. }

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.

1. [Fact]

2. public void GivenApplicationNotYetConnectedToDatabase_WhenCallingItems_ThenCreateConnectionToDatabase()

3. {

4. //Arrange

5. var databaseManagement = new Mock<IDatabaseManagement>();

6. databaseManagement.Setup(x => x.IsConnected()).Returns(false);

7. var arrayManagement = new ArrayManagement(databaseManagement.Object);

9. //Act

10. var actualValue = arrayManagement.SumOfItems();

12. //Assert

13. databaseManagement.Verify(x => x.Connect(), Times.Once);

14. }

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.

1. public int SumOfItems()

2. {

3. if (!_databaseManagement.IsConnected())

4. {

5. _databaseManagement.Connect();

6. }

7. var items = _databaseManagement.GetItemsFromDatabase();

8. if (items == null)

9. {

10. throw new ArgumentNullException();

11. }

12. var value = 0;

13. foreach (var item in items)

14. {

15. value += item;

16. }

17. return value;

18. }

À 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 :

1. public class ArrayManagement

2. {

3. IFileSystem _fileSystem;

4. public ArrayManagement(IFileSystem fileSystem)

5. {

6. _fileSystem = fileSystem;

7. }

9. private int[] GetArrayFromFile(string path)

10. {

11. var content = _fileSystem.File.ReadAllText(path);

12. var contentArray = content.Split(",");

13. var items = new int[contentArray.Length];

14. var i = 0;

15. foreach (var item in contentArray)

16. {

17. items[i] = int.Parse(item.ToString());

18. i++;

19. }

20. return items;

21. }

22. public int SumOfItems(string path)

23. {

24. var items = GetArrayFromFile(path);

25. if (items == null)

26. {

27. throw new ArgumentNullException();

28. }

29. var value = 0;

30. foreach (var item in items)

31. {

32. value += item;

33. }

34. return value;

35. }

36. }

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.