I. Introduction▲
ASP.NET Core apporte une refonte complète de la solution de développement Web de Microsoft. Le recours à un nouvel environnement d'exécution (DNX) et son architecture entraînent une incompatibilité avec de nombreux outils.
En effet, si vous développez un projet ASP.NET Core qui utilise uniquement le CoreCLR, vous ne devez qu'utiliser les dépendances qui offrent une prise en charge du CoreCLR.
Depuis l'annonce de .NET Core, de nombreux éditeurs de librairies se sont activés pour offrir une prise en charge de cette version, c'est notamment le cas des outils de tests dont MSTest et Moq.
Microsoft a publié une préversion de la V2 de MsTest, qui introduit une architecture unifiée et apporte une prise en charge de .NET Core. Dans le cadre de cet article, nous verrons comment mettre en place des tests unitaires dans un projet ASP.NET MVC Core.
Cet article est divisé en trois parties. La première partie présentera comment mettre en œuvre un projet de tests unitaires avec MsTest V2, la seconde partie portera sur les tests unitaires mockés et la troisième et dernière partie permettra de découvrir comment exploiter la fonctionnalité InMemory d'Entity Framework Core pour effectuer des tests d'intégration.
II. Prérequis▲
Des connaissances de base en développement .NET sont nécessaires pour la bonne compréhension de cet article.
III. Outils de développement▲
Pour la rédaction de cet article, j'ai utilisé :
- Visual Studio 2015 Update 3 ;
- ASP.NET Core 1.0 ;
- la préversion de MsTest V2 ;
- la version Alpha de Moq 4.6.38 ;
- Entity Framework Core 1.0.
IV. Code source▲
Vous pouvez télécharger le code source du projet d'exemple sur ma page GitHubma page GitHub.
V. Partie 1 - Tests unitaires d'une application MVC▲
V-A. Création du projet de test▲
Pour commencer, vous allez créer une application ASP.NET Core en utilisant le modèle Web Application.
Cela fait, vous allez ajouter un nouveau projet de type bibliothèque de classes à votre solution. Pour l'instant, il n'existe pas de modèle de projet pour la nouvelle version du framework de test de Microsoft. Cette prise en charge sera effective dans la version stable de l'outil, avec la sortie de Visual Studio 15.
Ajoutez les dépendances suivantes à votre projet en utilisant la console Nuget :
- MSTest.TestFramework ;
- MSTest.TestAdapter ;
- dotnet-test-mstest.
Le package MSTest.TestFramework permet d'installer le framework MSTest V2. Pour l'installer, cliquez sur Tools dans la barre de menu de Visual Studio, puis Nuget Package Manager, ensuite sur Package Manager Console. Assurez-vous que dans la zone Default Project, vous avez sélectionné votre projet de test :
Dans la console NuGet, tapez la commande suivante :
Install-Package MSTest.TestFramework -Pre
MSTest.TestAdapter est utilisé pour trouver et exécuter le framework de test sur lequel votre projet de test est basé. Pour l'installer, vous devez exécuter la commande suivante dans la console NuGet :
Install-Package MSTest.TestAdapter -Pre
Enfin, vous allez exécuter la commande suivante pour installer le dernier package NuGet :
Install-Package dotnet-test-mstest -Pre
Vous allez probablement obtenir un message d'erreur d'incompatibilité lors de la restauration des packages par NuGet. Ouvrez le fichier Project.json et remplacez :
"frameworks"
:
{
"netstandard1.6"
:
{
"imports"
:
"dnxcore50"
}
}
par :
"frameworks"
:
{
"netcoreapp1.0"
:
{
"imports"
:
[
"dnxcore50"
,
"portable-net45+win8"
],
"dependencies"
:
{
"Microsoft.NETCore.App"
:
{
"version"
:
"1.0.0"
,
"type"
:
"platform"
}
}
}
}
Vous remarquerez sans doute que nous avons marqué notre bibliothèque de classes comme une application (netcoreapp1.0). Cela est dû au fait que notre projet de test utilise le .NET CLI. Sa méthode Main sera fournie par le runner de mstest.
Vous devez par la suite ajouter au fichier Project.json la ligne suivante, pour spécifier que votre projet de test unitaire repose sur mstest :
"testRunner"
:
"mstest"
,
Ajoutez une référence au projet ASP.NET Core. Votre fichier Project.json devrait ressembler à ceci :
{
"version"
:
"1.0.0-*"
,
"testRunner"
:
"mstest"
,
"dependencies"
:
{
"dotnet-test-mstest"
:
"1.1.1-preview"
,
"MSTest.TestAdapter"
:
"1.0.3-preview"
,
"MSTest.TestFramework"
:
"1.0.1-preview"
,
"NETStandard.Library"
:
"1.6.0"
,
"SampleApp"
:
"1.0.0-*"
},
"frameworks"
:
{
"netcoreapp1.0"
:
{
"imports"
:
[
"dnxcore50"
,
"portable-net45+win8"
],
"dependencies"
:
{
"Microsoft.NETCore.App"
:
{
"version"
:
"1.0.0"
,
"type"
:
"platform"
}
}
}
}
}
Ajoutez un nouveau fichier HomeControllerTest.cs à votre projet de test. Ajoutez un appel à l'espace de nom Microsoft.VisualStudio.TestTools.UnitTesting :
using
Microsoft.
VisualStudio.
TestTools.
UnitTesting;
V-B. Le code à tester▲
Jetons maintenant un coup d'œil au fichier HomeController.cs. J'ai légèrement modifié ce dernier pour qu'il puisse répondre à nos attentes.
using
System;
using
System.
Collections.
Generic;
using
System.
Linq;
using
System.
Threading.
Tasks;
using
Microsoft.
AspNetCore.
Mvc;
namespace
SampleApp.
Controllers
{
public
class
HomeController :
Controller
{
public
IActionResult Index
(
)
{
return
View
(
);
}
public
IActionResult About
(
)
{
ViewData[
"Message"
]
=
"Your application description page."
;
return
View
(
);
}
public
IActionResult Contact
(
)
{
ViewData[
"Message"
]
=
"Your contact page."
;
return
View
(
);
}
public
IActionResult Error
(
)
{
return
View
(
"~/Views/Shared/Error.cshtml"
);
}
}
}
Nous allons écrire du code pour tester le ViewResult et une autre pour le ViewData.
V-C. Test du ViewResult▲
Le code que nous allons écrire va permettre de vérifier que la méthode d'action Error(), dont voici le code, retourne le bon ViewResult.
public
IActionResult Error
(
)
{
return
View
(
"~/Views/Shared/Error.cshtml"
);
}
Le code de test est le suivant :
[TestMethod]
public
void
Error_ReturnsErrorView
(
)
{
// Arrange
var
controller =
new
HomeController
(
);
var
errorView =
"~/Views/Shared/Error.cshtml"
;
// Act
var
viewResult =
controller.
Error
(
) as
ViewResult;
// Assert
Assert.
AreEqual
(
errorView,
viewResult.
ViewName);
}
V-D. Test du ViewData▲
Pour le ViewData, nous allons tester que notre méthode d'action renvoie le bon ViewData. Voici la méthode à tester :
public
IActionResult About
(
)
{
ViewData[
"Message"
]
=
"Your application description page."
;
return
View
(
);
}
La méthode de test pour effectuer cela est la suivante :
[TestMethod]
public
void
About_ReturnViewData
(
)
{
// Arrange
var
controller =
new
HomeController
(
);
var
viewData =
"Your application description page."
;
// Act
var
viewResult =
controller.
About
(
) as
ViewResult;
// Assert
Assert.
AreEqual
(
viewData,
viewResult.
ViewData[
"Message"
]
);
}
Le code complet est le suivant :
[TestClass]
public
class
HomeControllerTest
{
[TestMethod]
public
void
Error_ReturnErrorView
(
)
{
// Arrange
var
controller =
new
HomeController
(
);
var
errorView =
"~/Views/Shared/Error.cshtml"
;
// Act
var
viewResult =
controller.
Error
(
) as
ViewResult;
// Assert
Assert.
AreEqual
(
errorView,
viewResult.
ViewName);
}
[TestMethod]
public
void
About_ReturnViewData
(
)
{
// Arrange
var
controller =
new
HomeController
(
);
var
viewData =
"Your application description page."
;
// Act
var
viewResult =
controller.
About
(
) as
ViewResult;
// Assert
Assert.
AreEqual
(
viewData,
viewResult.
ViewData[
"Message"
]
);
}
}
Á l'exécution, vous allez obtenir le résultat suivant dans l'explorateur de test :
VI. Partie 2 : tests unitaires mockés▲
VI-A. Rappel sur le mocking▲
Lors du développement, il arrive fréquemment que dans une classe, nous fassions appel à plusieurs autres objets. Ce qui crée une dépendance entre les classes. Les tests unitaires ont pour objectifs de tester une unité de traitement (une méthode), sans avoir besoin de se soucier des dépendances avec d'autres classes (des objets qui sont appelés, et qui seront testés séparément).
Le but du mocking est de permettre aux développeurs de créer des objets simulés qui reproduisent le comportement désiré des objets réels à leur invocation. Ces objets simulés sont couramment appelés Mock.
Il existe de nombreux frameworks .NET qui permettent de mettre en œuvre facilement le mocking. Ces frameworks permettent généralement de créer dynamiquement des objets à partir d'interfaces ou de classes. Ils offrent au développeur la possibilité de spécifier quelles méthodes vont être appelées et dans quel ordre elles le seront.
Dans le cadre de ce tutoriel, nous utiliserons le framework Moq qui est une référence dans l'univers .NET. Ce dernier offre une prise en charge de .NET core.
VI-B. Description de l'application à tester▲
Pour la suite du tutoriel, nous allons utiliser Entity Framework. Je ne vais pas m'attarder sur l'intégration d'Entity Framewok au projet ASP.NET Core. Pour le faire, je vous invite à consulter ce billet de blogce billet de blog qui vous guidera dans ce processus.
Nous allons utiliser une base de données LocalDB avec une seule table. Notre application va permettre d'effectuer des opérations CRUD sur la table suivante :
Le code que nous allons tester utilise le pattern Repository et tire avantage des améliorations qui ont été apportées à ASP.NET Core pour offrir une meilleure prise en charge de l'injection des dépendances. Avec cette version, nous n'avons plus besoin, par exemple, de mettre en œuvre l'injection des dépendances au niveau du contrôleur. Vous verrez combien cela va faciliter l'écriture de nos tests unitaires mockés.
Pour mettre en œuvre ce pattern, vous devez ajouter à votre projet un nouveau dossier Repository, ensuite y ajouter l'interface suivante :
public
interface
IStudentsRepository
{
void
Add
(
Student student);
Task<
IEnumerable<
Student>>
GetAll
(
);
Task<
Student>
Find
(
int
id);
Task Remove
(
int
id);
void
Update
(
Student student);
Task Save
(
);
Task<
bool
>
StudentExists
(
int
id);
}
La classe qui hérite de cette interface contient le code pour effectuer des opérations CRUD sur la table Student :
public
class
StudentsRepository :
IStudentsRepository,
IDisposable
{
private
SampleAppContext _context;
public
StudentsRepository
(
SampleAppContext context)
{
_context =
context;
}
public
void
Add
(
Student student)
{
_context.
Add
(
student);
}
public
async
Task<
Student>
Find
(
int
id)
{
return
await
_context.
Students.
SingleOrDefaultAsync
(
m =>
m.
Id ==
id);
}
public
async
Task<
IEnumerable<
Student>>
GetAll
(
)
{
return
await
_context.
Students.
ToListAsync
(
);
}
public
async
Task Remove
(
int
id)
{
var
student =
await
_context.
Students.
SingleOrDefaultAsync
(
m =>
m.
Id ==
id);
_context.
Students.
Remove
(
student);
}
public
void
Update
(
Student student)
{
_context.
Update
(
student);
}
public
async
Task Save
(
)
{
await
_context.
SaveChangesAsync
(
);
}
public
async
Task<
bool
>
StudentExists
(
int
id)
{
return
await
_context.
Students.
AnyAsync
(
e =>
e.
Id ==
id);
}
private
bool
disposedValue =
false
;
// To detect redundant calls
protected
virtual
void
Dispose
(
bool
disposing)
{
if
(!
disposedValue)
{
if
(
disposing)
{
_context.
Dispose
(
);
}
disposedValue =
true
;
}
}
// This code added to correctly implement the disposable pattern.
public
void
Dispose
(
)
{
// Do not change this code. Put cleanup code in Dispose(bool disposing) above.
Dispose
(
true
);
// TODO: uncomment the following line if the finalizer is overridden above.
GC.
SuppressFinalize
(
this
);
}
}
Vous devez par la suite ajouter votre interface et la classe au conteneur d'injection de dépendances de ASP.NET Core. Pour cela, vous devez éditer le fichier Startup.cs et ajouter la ligne de code suivante dans la méthode ConfigureServices :
services.
AddScoped<
IStudentsRepository,
StudentsRepository>(
);
Nous allons maintenant créer un contrôleur ayant une référence à cette classe, avec des actions pour effectuer des opérations CRUD. Vous devez donc ajouter un fichier StudentsController.cs à votre projet, avec le code suivant :
public
class
StudentsController :
Controller
{
private
readonly
IStudentsRepository _studentsRepository;
public
StudentsController
(
IStudentsRepository studentsRepository)
{
_studentsRepository =
studentsRepository;
}
// GET: Students
public
async
Task<
IActionResult>
Index
(
)
{
return
View
(
await
_studentsRepository.
GetAll
(
));
}
// GET: Students/Details/5
public
async
Task<
IActionResult>
Details
(
int
?
id)
{
if
(
id ==
null
)
{
return
NotFound
(
);
}
var
student =
await
_studentsRepository.
Find
(
id.
Value);
if
(
student ==
null
)
{
return
NotFound
(
);
}
return
View
(
student);
}
// GET: Students/Create
public
IActionResult Create
(
)
{
return
View
(
);
}
// POST: Students/Create
// To protect from overposting attacks, please enable the specific properties you want to bind to, for
// more details see http://go.microsoft.com/fwlink/?LinkId=317598.
[HttpPost]
[ValidateAntiForgeryToken]
public
async
Task<
IActionResult>
Create
([
Bind
(
"Id,Email,FirstName,LastName"
)]
Student student)
{
if
(
ModelState.
IsValid)
{
_studentsRepository.
Add
(
student);
await
_studentsRepository.
Save
(
);
return
RedirectToAction
(
"Index"
);
}
return
View
(
student);
}
// GET: Students/Edit/5
public
async
Task<
IActionResult>
Edit
(
int
?
id)
{
if
(
id ==
null
)
{
return
NotFound
(
);
}
var
student =
await
_studentsRepository.
Find
(
id.
Value);
if
(
student ==
null
)
{
return
NotFound
(
);
}
return
View
(
student);
}
// POST: Students/Edit/5
// To protect from overposting attacks, please enable the specific properties you want to bind to, for
// more details see http://go.microsoft.com/fwlink/?LinkId=317598.
[HttpPost]
[ValidateAntiForgeryToken]
public
async
Task<
IActionResult>
Edit
(
int
id,
[
Bind
(
"Id,Email,FirstName,LastName"
)]
Student student)
{
if
(
id !=
student.
Id)
{
return
NotFound
(
);
}
if
(
ModelState.
IsValid)
{
try
{
_studentsRepository.
Update
(
student);
await
_studentsRepository.
Save
(
);
}
catch
(
DbUpdateConcurrencyException)
{
if
(!
await
_studentsRepository.
StudentExists
(
student.
Id))
{
return
NotFound
(
);
}
else
{
throw
;
}
}
return
RedirectToAction
(
"Index"
);
}
return
View
(
student);
}
// GET: Students/Delete/5
public
async
Task<
IActionResult>
Delete
(
int
?
id)
{
if
(
id ==
null
)
{
return
NotFound
(
);
}
var
student =
await
_studentsRepository.
Find
(
id.
Value);
if
(
student ==
null
)
{
return
NotFound
(
);
}
return
View
(
student);
}
// POST: Students/Delete/5
[HttpPost, ActionName(
"Delete"
)]
[ValidateAntiForgeryToken]
public
async
Task<
IActionResult>
DeleteConfirmed
(
int
id)
{
await
_studentsRepository.
Remove
(
id);
await
_studentsRepository.
Save
(
);
return
RedirectToAction
(
"Index"
);
}
}
Les méthodes de tests unitaires que nous écrirons permettront de tester les actions de ce contrôleur.
VI-C. Installation du package Moq▲
Revenons à notre projet de test. La première chose à faire sera d'installer le package Moq dans ce dernier en utilisant la console NuGet. La commande à utiliser est la suivante :
Install-
Package Moq -
Pre
Lorsque c'est fait, votre fichier project.json devrait ressembler à ceci :
{
"version"
:
"1.0.0-*"
,
"testRunner"
:
"mstest"
,
"dependencies"
:
{
"dotnet-test-mstest"
:
"1.1.1-preview"
,
"Moq"
:
"4.6.38-alpha"
,
"MSTest.TestAdapter"
:
"1.0.3-preview"
,
"MSTest.TestFramework"
:
"1.0.1-preview"
,
"NETStandard.Library"
:
"1.6.0"
,
"SampleApp"
:
"1.0.0-*"
},
"frameworks"
:
{
"netcoreapp1.0"
:
{
"imports"
:
[
"dnxcore50"
,
"portable-net45+win8"
],
"dependencies"
:
{
"Microsoft.NETCore.App"
:
{
"version"
:
"1.0.0"
,
"type"
:
"platform"
}
}
}
}
}
Vous remarquez la présence de "Moq": "4.6.38-alpha".
VI-D. Écriture des tests unitaires▲
Voici la première méthode pour laquelle nous allons écrire des tests :
// GET: Students
public
async
Task<
IActionResult>
Index
(
)
{
return
View
(
await
_studentsRepository.
GetAll
(
));
}
La méthode de test que nous allons écrire doit permettre de vérifier que le ViewResult contient la liste d'éléments qui a été retournée par le repository.
Nous allons premièrement créer un objet simulé de notre repository à partir de son interface :
var
studentsRepositoryMock =
new
Mock<
IStudentsRepository>(
);
Par la suite, nous allons changer le comportement de notre repository pour que lorsque la méthode GetAll() sera appelée dans notre méthode à tester, une autre méthode soit utilisée à la place :
studentsRepositoryMock.
Setup
(
repo =>
repo.
GetAll
(
)).
Returns
(
Task.
FromResult
(
GetTestStudents
(
)));
La méthode qui sera appelée à la place est GetTestStudents(), qui retourne une liste d'étudiants. Voici son code :
private
IEnumerable<
Student>
GetTestStudents
(
)
{
IEnumerable<
Student>
students =
new
List<
Student>(
) {
new
Student {
Id =
1
,
Email =
"j.papavoisi@gmail.com"
,
FirstName=
"Papavoisi"
,
LastName=
"Jean"
},
new
Student {
Id =
2
,
Email =
"p.garden@gmail.com"
,
FirstName =
"Garden"
,
LastName =
"Pierre"
},
new
Student {
Id =
3
,
Email =
"r.derosi@gmail.com"
,
FirstName =
"Derosi"
,
LastName =
"Ronald"
}
};
return
students;
}
Cela fait, nous allons passer l'instance de notre objet mocké au constructeur de StudentsController :
var
controller =
new
StudentsController
(
studentsRepositoryMock.
Object);
Par la suite, nous devons ajouter les assertions pour vérifier que le ViewResult retourne la liste d'éléments attendus :
Assert.
IsNotNull
(
viewResult);
var
students =
viewResult.
ViewData.
Model as
List<
Student>;
Assert.
AreEqual
(
3
,
students.
Count);
Le code complet de notre méthode de test est le suivant :
[TestMethod]
public
async
Task Index_ReturnsAllStudents
(
)
{
//Arrange
var
studentsRepositoryMock =
new
Mock<
IStudentsRepository>(
);
studentsRepositoryMock.
Setup
(
repo =>
repo.
GetAll
(
)).
Returns
(
Task.
FromResult
(
GetTestStudents
(
)));
var
controller =
new
StudentsController
(
studentsRepositoryMock.
Object);
// Act
var
viewResult =
await
controller.
Index
(
) as
ViewResult;
//assert
Assert.
IsNotNull
(
viewResult);
var
students =
viewResult.
ViewData.
Model as
List<
Student>;
Assert.
AreEqual
(
3
,
students.
Count);
}
Pour la suite, nous allons rédiger les tests pour la méthode d'action Details :
public
async
Task<
IActionResult>
Details
(
int
?
id)
{
if
(
id ==
null
)
{
return
NotFound
(
);
}
var
student =
await
_studentsRepository.
Find
(
id.
Value);
if
(
student ==
null
)
{
return
NotFound
(
);
}
return
View
(
student);
}
Pour ce cas, nous allons rédiger un test qui permet de vérifier que le ViewResult contient un objet étudiant, et deux autres pour vérifier qu'un NotFound result est retourné.
Pour le premier cas, la méthode _studentsRepository.Find(id.Value) est appelée dans notre action. Nous allons donc configurer notre objet mocké pour retourner un étudiant lorsque cette méthode est appelée avec une valeur précise en paramètre :
studentsRepositoryMock.
Setup
(
repo =>
repo.
Find
(
2
)).
Returns
(
Task.
FromResult
(
GetTestStudents
(
).
ElementAt
(
1
)));
On va faire une assertion pour vérifier que l'information attendue est contenue dans le ViewResult :
Assert.
IsNotNull
(
viewResult);
var
student =
viewResult.
ViewData.
Model as
Student;
Assert.
AreEqual
(
"Garden"
,
student.
FirstName);
Le code complet de la méthode de test est le suivant :
[TestMethod]
public
async
Task Details_ReturnsStudent
(
)
{
//Arrange
var
studentsRepositoryMock =
new
Mock<
IStudentsRepository>(
);
studentsRepositoryMock.
Setup
(
repo =>
repo.
Find
(
2
)).
Returns
(
Task.
FromResult
(
GetTestStudents
(
).
ElementAt
(
1
)));
var
controller =
new
StudentsController
(
studentsRepositoryMock.
Object);
// Act
var
viewResult =
await
controller.
Details
(
2
) as
ViewResult;
//assert
Assert.
IsNotNull
(
viewResult);
var
student =
viewResult.
ViewData.
Model as
Student;
Assert.
AreEqual
(
"Garden"
,
student.
FirstName);
}
Pour le cas du NotFound result, nous avons deux cas de figure :
- l'étudiant dont l'id a été spécifié n'a pas été trouvé ;
- l'id passé est null.
Pour le premier cas, nous allons configurer notre objet mocké pour qu'il retourne null, lorsque la méthode Find() du repository est appelée avec la valeur « 2 » en paramètre :
studentsRepositoryMock.
Setup
(
repo =>
repo.
Find
(
2
)).
Returns
(
Task.
FromResult<
Student>(
null
));
Ensuite, on fait une assertion pour vérifier qu'un NotFoundResult est retourné :
Assert.
IsInstanceOfType
(
actionResult,
typeof
(
NotFoundResult));
Le code complet :
[TestMethod]
public
async
Task Details_ReturnsNotFoundWithId
(
)
{
//Arrange
var
studentsRepositoryMock =
new
Mock<
IStudentsRepository>(
);
studentsRepositoryMock.
Setup
(
repo =>
repo.
Find
(
2
)).
Returns
(
Task.
FromResult<
Student>(
null
));
var
controller =
new
StudentsController
(
studentsRepositoryMock.
Object);
// Act
IActionResult actionResult =
await
controller.
Details
(
2
) ;
//assert
Assert.
IsInstanceOfType
(
actionResult,
typeof
(
NotFoundResult));
}
Pour le deuxième cas, nous n'aurons pas besoin de changer le comportement de notre objet mocké, car il ne sera pas appelé. Nous devons juste passer une valeur nulle à notre méthode d'action, ensuite vérifier qu'on obtient un NotFound result. Le code complet de cette méthode de test est le suivant :
[TestMethod]
public
async
Task Details_ReturnsNotFoundWithNullId
(
)
{
//Arrange
var
studentsRepositoryMock =
new
Mock<
IStudentsRepository>(
);
var
controller =
new
StudentsController
(
studentsRepositoryMock.
Object);
// Act
IActionResult actionResult =
await
controller.
Details
(
null
);
//assert
Assert.
IsInstanceOfType
(
actionResult,
typeof
(
NotFoundResult));
}
Passons maintenant à la rédaction des tests unitaires pour la méthode d'action Create, dont voici le code :
[HttpPost]
[ValidateAntiForgeryToken]
public
async
Task<
IActionResult>
Create
([
Bind
(
"Id,Email,FirstName,LastName"
)]
Student student)
{
if
(
ModelState.
IsValid)
{
_studentsRepository.
Add
(
student);
await
_studentsRepository.
Save
(
);
return
RedirectToAction
(
"Index"
);
}
return
View
(
student);
}
Pour ce cas, nous allons rédiger deux tests :
- l'un qui permettra de vérifier la redirection ;
- l'autre pour le cas où le ModelState est invalide.
Pour le premier cas, le code de la méthode de test permettant d'effectuer cela est le suivant :
[TestMethod]
public
async
Task Create_ReturnsRedirectToAction
(
)
{
//Arrange
var
studentsRepositoryMock =
new
Mock<
IStudentsRepository>(
);
var
controller =
new
StudentsController
(
studentsRepositoryMock.
Object);
// Act
var
result =
await
controller.
Create
(
new
Student {
Id=
4
,
Email=
"a.Damien@gmail.com"
,
FirstName=
"Damien"
,
LastName=
"Alain"
}
) as
RedirectToActionResult;
//assert
Assert.
AreEqual
(
"Index"
,
result.
ActionName);
}
Pour le second cas, nous devons modifier notre contrôleur pour que son model state soit invalide :
controller.
ModelState.
AddModelError
(
"Email"
,
"Required"
);
En effet, les tests unitaires se font sur une méthode isolée. L'appel de la méthode Create exécute uniquement cette dernière. De ce fait, il n'y a aucun passage au travers du pipeline ASP.NET MVC, qui devait s'occuper du binding du modèle et de la validation.
Le code complet pour notre méthode de test est le suivant :
[TestMethod]
public
async
Task Create_InvalidModelState
(
)
{
//Arrange
var
studentsRepositoryMock =
new
Mock<
IStudentsRepository>(
);
var
controller =
new
StudentsController
(
studentsRepositoryMock.
Object);
// Act
controller.
ModelState.
AddModelError
(
"Email"
,
"Required"
);
var
result =
await
controller.
Create
(
new
Student (
)) as
ViewResult;
var
student =
result.
Model as
Student;
//assert
Assert.
IsNotNull
(
student);
}
Á l'exécution, vous obtenez le résultat suivant :
Je crois qu'avec ces quelques exemples, j'ai couvert différents scénarios pour les tests unitaires mockés d'un contrôleur avec des actions CRUD. Vous devez être en mesure d'écrire sans beaucoup d'effort les tests pour couvrir les autres méthodes d'action.
VII. Partie 3 : tests d'intégration avec Entity Framework Core InMemory▲
Avec les mocks, le développeur peut tester une unité de traitement (une méthode), sans avoir besoin de se soucier des dépendances avec d'autres classes. En isolant la méthode à tester, il est rassuré que si le test échoue, la cause réside dans le code et pas ailleurs.
Toutefois, dans le cycle de développement, le développeur va arriver à une phase où il aura besoin de tester au complet une fonctionnalité qui fait intervenir plusieurs unités de traitement. Á ce stade, on parle couramment de test d'intégration.
Prenons l'exemple d'une application ASP.NET MVC qui utilise Entity Framework et une base de données SQL Server. Pour effectuer des tests d'intégration, sans avoir à impacter la base de données existante, le développeur va mettre des efforts sur la duplication de son « contexte », qui sera utilisé pour les tests.
Entity Framework Core apporte le concept de base de données en mémoire (InMemory). Le provider InMemory permet de tester des composants en simulant un accès à la base de données comme dans un contexte d'utilisation réelle, sans toutefois impacter la base de données existante. De plus, cette option réduit les efforts pour mettre en œuvre le mocking.
VII-A. Ajout du package EntityFrameworkCore.InMemory▲
La première chose à faire sera d'ajouter une référence au package « Microsoft.EntityFrameworkCore.InMemory » dans le projet de test. Tapez la commande suivante dans la console NuGet :
Install-Package Microsoft.EntityFrameworkCore.InMemory
VII-B. Écriture des méthodes de tests▲
Vous allez ajouter au projet de test un nouveau fichier .CS. Pour commencer, nous devons créer une méthode qui va permettre de définir les options du DbContext (DbContextOptions).
private
static
DbContextOptions<
SampleAppContext>
CreateNewContextOptions
(
)
{
}
Dans cette méthode, nous allons dans un premier temps créer un nouveau ServiceProvider, qui va entraîner la génération d'une nouvelle instance d'une base de données InMemory.
var
serviceProvider =
new
ServiceCollection
(
)
.
AddEntityFrameworkInMemoryDatabase
(
)
.
BuildServiceProvider
(
);
Ensuite, nous allons créer une nouvelle instance du DbContextOptions, qui va permettre de spécifier à notre DbContext que nous souhaitons utiliser une base de données InMemory et notre nouveau serviceProvider. Le code pour effectuer cela est le suivant :
var
builder =
new
DbContextOptionsBuilder<
SampleAppContext>(
);
builder.
UseInMemoryDatabase
(
)
.
UseInternalServiceProvider
(
serviceProvider);
Pour finir, nous allons retourner nos nouvelles options pour notre DbContext :
return
builder.
Options;
Le code complet de cette méthode est le suivant :
private
static
DbContextOptions<
SampleAppContext>
CreateNewContextOptions
(
)
{
var
serviceProvider =
new
ServiceCollection
(
)
.
AddEntityFrameworkInMemoryDatabase
(
)
.
BuildServiceProvider
(
);
var
builder =
new
DbContextOptionsBuilder<
SampleAppContext>(
);
builder.
UseInMemoryDatabase
(
)
.
UseInternalServiceProvider
(
serviceProvider);
return
builder.
Options;
}
Dans notre stratégie de test, nous souhaitons que chaque méthode de test s'exécute avec une base de données InMemory contenant un certain nombre d'informations. Pour cela, nous devons ajouter à notre test une méthode d'initialisation ayant l'attribut [TestInitialize]
:
[TestInitialize]
public
async
Task Init
(
)
{
}
Dans cette méthode, nous allons écrire le code permettant d'initialiser notre base de données InMemory.
[TestInitialize]
public
async
Task Init
(
)
{
var
options =
CreateNewContextOptions
(
);
_studentRepository =
new
StudentsRepository
(
new
SampleAppContext
(
options));
// var service = new StudentsRepository(context);
_studentRepository.
Add
(
new
Student {
Id =
1
,
Email =
"j.papavoisi@gmail.com"
,
FirstName =
"Papavoisi"
,
LastName =
"Jean"
}
);
_studentRepository.
Add
(
new
Student {
Id =
2
,
Email =
"p.garden@gmail.com"
,
FirstName =
"Garden"
,
LastName =
"Pierre"
}
);
_studentRepository.
Add
(
new
Student {
Id =
3
,
Email =
"r.derosi@gmail.com"
,
FirstName =
"Derosi"
,
LastName =
"Ronald"
}
);
await
_studentRepository.
Save
(
);
}
Passons maintenant à l'écriture de nos méthodes de test. Nous allons reprendre le contrôleur de notre exemple précèdent. J'écrirais juste quelques méthodes de test pour illustrer l'utilisation de InMemory de Entity Framework.
Commençons par la méthode d'action Index(), qui retourne la liste des étudiants :
public
async
Task<
IActionResult>
Index
(
)
{
return
View
(
await
_studentsRepository.
GetAll
(
));
}
Le code de test pour cette dernière est le suivant :
[TestMethod]
public
async
Task Index_ReturnsAllStudentsIn
(
)
{
//Arrange
var
controller =
new
StudentsController
(
_studentRepository);
// Act
var
viewResult =
await
controller.
Index
(
) as
ViewResult;
//assert
Assert.
IsNotNull
(
viewResult);
var
students =
viewResult.
ViewData.
Model as
List<
Student>;
Assert.
AreEqual
(
3
,
students.
Count);
}
Passons à la méthode d'action Details :
public
async
Task<
IActionResult>
Details
(
int
?
id)
{
if
(
id ==
null
)
{
return
NotFound
(
);
}
var
student =
await
_studentsRepository.
Find
(
id.
Value);
if
(
student ==
null
)
{
return
NotFound
(
);
}
return
View
(
student);
}
Le code pour tester ce dernier avec un id qui existe dans la base de données est le suivant :
[TestMethod]
public
async
Task Details_ReturnStudentIn
(
)
{
//Arrange
var
controller =
new
StudentsController
(
_studentRepository);
// Act
var
viewResult =
await
controller.
Details
(
2
) as
ViewResult;
//assert
Assert.
IsNotNull
(
viewResult);
var
student =
viewResult.
ViewData.
Model as
Student;
Assert.
AreEqual
(
"Garden"
,
student.
FirstName);
}
Enfin, nous allons écrire le code pour tester la méthode d'action Create() :
public
async
Task<
IActionResult>
Create
([
Bind
(
"Id,Email,FirstName,LastName"
)]
Student student)
{
if
(
ModelState.
IsValid)
{
_studentsRepository.
Add
(
student);
await
_studentsRepository.
Save
(
);
return
RedirectToAction
(
"Index"
);
}
return
View
(
student);
}
Pour ce dernier cas, voici le code de la méthode de test correspondante :
[TestMethod]
public
async
Task Create_ReturnsRedirectToActionIn
(
)
{
//Arrange
var
controller =
new
StudentsController
(
_studentRepository);
// Act
var
result =
await
controller.
Create
(
new
Student {
Id =
4
,
Email =
"a.Damien@gmail.com"
,
FirstName =
"Damien"
,
LastName =
"Alain"
}
) as
RedirectToActionResult;
//assert
Assert.
IsNotNull
(
result);
Assert.
AreEqual
(
"Index"
,
result.
ActionName);
}
Pour finir, ci-dessous le code complet de notre classe de test :
[TestClass]
public
class
StudentsControllerTestIN
{
private
IStudentsRepository _studentRepository;
private
static
DbContextOptions<
SampleAppContext>
CreateNewContextOptions
(
)
{
var
serviceProvider =
new
ServiceCollection
(
)
.
AddEntityFrameworkInMemoryDatabase
(
)
.
BuildServiceProvider
(
);
var
builder =
new
DbContextOptionsBuilder<
SampleAppContext>(
);
builder.
UseInMemoryDatabase
(
)
.
UseInternalServiceProvider
(
serviceProvider);
return
builder.
Options;
}
[TestInitialize]
public
async
Task Init
(
)
{
var
options =
CreateNewContextOptions
(
);
_studentRepository =
new
StudentsRepository
(
new
SampleAppContext
(
options));
// var service = new StudentsRepository(context);
_studentRepository.
Add
(
new
Student {
Id =
1
,
Email =
"j.papavoisi@gmail.com"
,
FirstName =
"Papavoisi"
,
LastName =
"Jean"
}
);
_studentRepository.
Add
(
new
Student {
Id =
2
,
Email =
"p.garden@gmail.com"
,
FirstName =
"Garden"
,
LastName =
"Pierre"
}
);
_studentRepository.
Add
(
new
Student {
Id =
3
,
Email =
"r.derosi@gmail.com"
,
FirstName =
"Derosi"
,
LastName =
"Ronald"
}
);
await
_studentRepository.
Save
(
);
}
[TestMethod]
public
async
Task Index_ReturnsAllStudentsIn
(
)
{
//Arrange
var
controller =
new
StudentsController
(
_studentRepository);
// Act
var
viewResult =
await
controller.
Index
(
) as
ViewResult;
//assert
Assert.
IsNotNull
(
viewResult);
var
students =
viewResult.
ViewData.
Model as
List<
Student>;
Assert.
AreEqual
(
3
,
students.
Count);
}
[TestMethod]
public
async
Task Details_ReturnStudentIn
(
)
{
//Arrange
var
controller =
new
StudentsController
(
_studentRepository);
// Act
var
viewResult =
await
controller.
Details
(
2
) as
ViewResult;
//assert
Assert.
IsNotNull
(
viewResult);
var
student =
viewResult.
ViewData.
Model as
Student;
Assert.
AreEqual
(
"Garden"
,
student.
FirstName);
}
[TestMethod]
public
async
Task Create_ReturnsRedirectToActionIn
(
)
{
//Arrange
var
controller =
new
StudentsController
(
_studentRepository);
// Act
var
result =
await
controller.
Create
(
new
Student {
Id =
4
,
Email =
"a.Damien@gmail.com"
,
FirstName =
"Damien"
,
LastName =
"Alain"
}
) as
RedirectToActionResult;
//assert
Assert.
IsNotNull
(
result);
Assert.
AreEqual
(
"Index"
,
result.
ActionName);
}
}
Á l'exécution, vous obtenez le résultat suivant :
VIII. Conclusion▲
Grâce aux tests unitaires, vous pouvez améliorer la qualité de votre application et faciliter la maintenance de cette dernière. Suite à la lecture de cet article, vous serez désormais en mesure d'écrire des tests unitaires pour une application ASP.NET Core, en utilisant diverses techniques.
IX. Remerciements ▲
Je tiens à remercier milkoseckmilkoseck pour sa relecture orthographique.