ASP.NET Core : mise en œuvre des tests unitaires dans une application MVC

Hinault Romaric

Les tests unitaires sont utilisés par le programmeur pour tester indépendamment des unités de traitement (méthodes) et s'assurer de leur bon fonctionnement. Les tests unitaires offrent plus de robustesse au code et permettent de faire des opérations de maintenance sur le code sans que celui-ci ne subisse de régression.

Commentez

- Titre : ASP.NET Core : mise en œuvre des tests unitaires dans une application MVC

- Auteur : Hinault Romaric

- Parution : 2 octobre 2016

- 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 ® 2016 Hinault Romaric. 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. Droits de diffusion permanents accordés à Developpez LLC.

Sommaire

- I. Introduction

- II. Prérequis

- III. Outils de développement

- IV. Code source

- V. Partie 1 - Tests unitaires d'une application MVC

- V-A. Création du projet de test

- V-B. Le code à tester

- V-C. Test du ViewResult

- V-D. Test du ViewData

- VI. Partie 2 : tests unitaires mockés

- VI-A. Rappel sur le mocking

- VI-B. Description de l'application à tester

- VI-C. Installation du package Moq

- VI-D. Écriture des tests unitaires

- VII. Partie 3 : tests d'intégration avec Entity Framework Core InMemory

- VII-A. Ajout du package EntityFrameworkCore.InMemory

- VII-B. Écriture des méthodes de tests

- VIII. Conclusion

- IX. Remerciements

- X. Références

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 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 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 milkoseck pour sa relecture orthographique.

X - Références

- La documentation officielle d'ASP.NET Core ;

- La documentation oficielle d'Entity Framework Core ;

- Création d'une application CRUD avec ASP.NET Core et Entity Framework Core.