Dans ce nouveau billet de blog, nous irons un peu plus loin et mous verrons comment écrire des tests unitaires mockés avec MsTest V2 et Moq.
Petit 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 ne charge de .NET core.
Ajout du package Moq au projet
Nous allons reprendre notre projet de tests du billet de blog précèdent. La première chose à faire sera d’installer le package Moq dans le projet de tests en utilisant la console NuGet. La commande à utiliser est la suivante :
Code : | Sélectionner tout |
Install-Package Moq -Pre
Code json : | Sélectionner tout |
1 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 | { "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".
Le contrôleur à tester
Nous allons tester un contrôleur qui dispose d’actions CRUD. Le code complet de ce contrôleur est le suivant :
Code c# : | Sélectionner tout |
1 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 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 | using System; using System.Collections.Generic; using System.Linq; using System.Threading.Tasks; using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc.Rendering; using Microsoft.EntityFrameworkCore; using SampleApp.Models; using SampleApp.Repository; namespace SampleApp.Controllers { 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) { await _studentsRepository.Add(student); 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 { await _studentsRepository.Update(student); } 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); return RedirectToAction("Index"); } } } |
Je ne vais pas écrire des tests pour avoir une couverture totale de ce code. Je vais me limiter au nécessaire permettant d’avoir divers scénarios.
Le code que nous devons 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 constructeur. Vous verrez combien cela va faciliter l’écriture de nos tests unitaires mockés.
Trêve de bavardage. Passons à la pratique.
Écriture des tests unitaires
Voici la première méthode pour laquelle nous allons écrire un test :
Code c# : | Sélectionner tout |
1 2 3 4 5 | // 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 :
Code c# : | Sélectionner tout |
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 a tester, une autre méthode soit utilisée à la place :
Code c# : | Sélectionner tout |
studentsRepositoryMock.Setup(repo => repo.GetAll()).Returns(Task.FromResult(GetTestStudents()));
La méthode qui est sera appelée à la place est GetTestStudents(), qui retourne une liste d’étudiants. Voici son code :
Code c# : | Sélectionner tout |
1 2 3 4 5 6 7 8 9 10 | 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; } |
Ceci fait, nous allons passer l’instance de notre objet mocké au constructeur de StudentsController :
Code c# : | Sélectionner tout |
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 :
Code c# : | Sélectionner tout |
1 2 3 | 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 :
Code c# : | Sélectionner tout |
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 | [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 :
Code c# : | Sélectionner tout |
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 | 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 :
Code c# : | Sélectionner tout |
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 :
Code c# : | Sélectionner tout |
1 2 3 | 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 :
Code c# : | Sélectionner tout |
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 | [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’etudiant 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 nul, lorsque la méthode Find() du repository est appelée avec la valeur “2” en paramètre :
Code c# : | Sélectionner tout |
studentsRepositoryMock.Setup(repo => repo.Find(2)).Returns(Task.FromResult<Student>(null));
Ensuite, on fait une assertion pour vérifier qu’un NotFoundResult est retourné :
Code : | Sélectionner tout |
=c#Assert.IsInstanceOfType(actionResult, typeof(NotFoundResult));
Code c# : | Sélectionner tout |
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 | [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 a 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 :
Code c# : | Sélectionner tout |
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 | [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 :
Code c# : | Sélectionner tout |
1 2 3 4 5 6 7 8 9 10 11 | [HttpPost] [ValidateAntiForgeryToken] public async Task<IActionResult> Create([Bind("Id,Email,FirstName,LastName")] Student student) { if (ModelState.IsValid) { await _studentsRepository.Add(student); 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 :
Code c# : | Sélectionner tout |
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 | [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.IsNotNull(result); Assert.AreEqual("Index", result.ActionName); } |
Pour le second cas, nous devons modifier notre contrôleur pour que son model state soit invalide :
Code : | Sélectionner tout |
controller.ModelState.AddModelError("Email", "Required");
Le code complet pour notre méthode de test est le suivant :
Code c# : | Sélectionner tout |
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 | public async Task Create_InvalidModelState() { //Arrange var studentsRepositoryMock = new Mock<IStudentsRepository>(); var controller = new StudentsController(studentsRepositoryMock.Object); // Act controller.ModelState.AddModelError("Email", "Required"); var viewResult = await controller.Create(new Student ()) as ViewResult; //assert Assert.IsNotNull(viewResult); var student = viewResult.Model as Student; Assert.IsNotNull(student); } |
Le code complet de notre classe de tests est le suivant :
Code c# : | Sélectionner tout |
1 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 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 | [TestClass] public class StudentsControllerTest { [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); } [TestMethod] public async Task Details_ReturnStudent() { //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); } [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)); } [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)); } [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.IsNotNull(result); Assert.AreEqual("Index", result.ActionName); } [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 viewResult = await controller.Create(new Student ()) as ViewResult; //assert Assert.IsNotNull(viewResult); var student = viewResult.Model as Student; Assert.IsNotNull(student); } 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; } } |
A l’exécution, on obtient 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.
Dans mon prochain billet, nous verrons comment rédiger des tests d’intégration en exploitant la fonctionnalité InMemory de Entity Framework Core. Restez connecter !