[Actualité] ASP.NET Core : Tests unitaires d’une application MVC avec MsTest V2 et des objets Mocks
par
, 30/08/2016 à 02h10 (3567 Affichages)
Dans mon précèdent billet de blog, j’ai présenté MsTest V2, la nouvelle version du framework de tests unitaires de Microsoft. Cette version est encore au stade de preview. Elle supporte le framework .NET Core. Dans ce billet, nous avons vu comment intégrer MsTest V2 à un projet et écrire des tests unitaires pour une application ASP.NET MVC Core.
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 :
Lorsque c’est fait, votre fichier project.json devrait ressembler à ceci :
Code : Sélectionner tout - Visualiser dans une fenêtre à part Install-Package Moq -Pre
Code json : Sélectionner tout - Visualiser dans une fenêtre à part
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 - Visualiser dans une fenêtre à part
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 - Visualiser dans une fenêtre à part
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 - Visualiser dans une fenêtre à part 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 - Visualiser dans une fenêtre à part 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 - Visualiser dans une fenêtre à part
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 - Visualiser dans une fenêtre à part 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 - Visualiser dans une fenêtre à part
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 - Visualiser dans une fenêtre à part
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 - Visualiser dans une fenêtre à part
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 - Visualiser dans une fenêtre à part 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 - Visualiser dans une fenêtre à part
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 - Visualiser dans une fenêtre à part
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 - Visualiser dans une fenêtre à part studentsRepositoryMock.Setup(repo => repo.Find(2)).Returns(Task.FromResult<Student>(null));
Ensuite, on fait une assertion pour vérifier qu’un NotFoundResult est retourné :
Le code complet :
Code : Sélectionner tout - Visualiser dans une fenêtre à part =c#Assert.IsInstanceOfType(actionResult, typeof(NotFoundResult));
Code c# : Sélectionner tout - Visualiser dans une fenêtre à part
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 - Visualiser dans une fenêtre à part
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 - Visualiser dans une fenêtre à part
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 - Visualiser dans une fenêtre à part
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 :
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 model et de la validation.
Code : Sélectionner tout - Visualiser dans une fenêtre à part controller.ModelState.AddModelError("Email", "Required");
Le code complet pour notre méthode de test est le suivant :
Code c# : Sélectionner tout - Visualiser dans une fenêtre à part
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 - Visualiser dans une fenêtre à part
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 !