ASP.NET Core : apprendre l'exécution de tests unitaires dans une application MVC avec MsTest V2 et des objets Mock,
Un tutoriel de Hinault Romaric

Le , par Hinault Romaric, Responsable .NET
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 :

Code : Sélectionner tout
Install-Package Moq -Pre
Lorsque c’est fait, votre fichier project.json devrait ressembler à ceci :

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));
Le code complet :

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");
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.

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 !


Vous avez aimé cette actualité ? Alors partagez-la avec vos amis en cliquant sur les boutons ci-dessous :


 Poster un commentaire

Avatar de Aizen64 Aizen64 - Membre averti https://www.developpez.com
le 12/09/2016 à 14:01
Il y a une erreur de typo, c'est pas plus FirstName et non FristName (inversion du i et du r) ?

.NET Core est parfaitement utilisable sous OS X et Linux ou c'est encore bancal ?

J'aimerai bien faire des tests.
Avatar de Hinault Romaric Hinault Romaric - Responsable .NET https://www.developpez.com
le 16/09/2016 à 19:00
Citation Envoyé par Aizen64;bt3097
Il y a une erreur de typo, c'est pas plus FirstName et non FristName (inversion du i et du r) ?

.NET Core est parfaitement utilisable sous OS X et Linux ou c'est encore bancal ?

J'aimerai bien faire des tests.

Une erreur de frappe. C'est corrigé. Merci.

.NET est utilisable sur Linux et OS X, et est assez stable. L’éditeur Visual Studio Code offre une prise en charge acceptable. De plus, il y a des générateurs pour bénéficier des modèles de projet de Visual Studio. Je devrais bientôt publier un billet sur le sujet.
Offres d'emploi IT
Tourneur Fraiseur CN (H/F)
AGENCE SUPPLAY - Champagne Ardennes - Charleville-Mézières (08090)
Développeur informatique (h/f)
B&B Hôtels - Bretagne - Brest (29200)
Consultant AMOA Assurances (H/F)
Atos Technology Services - Centre - Orléans (45000)

Voir plus d'offres Voir la carte des offres IT
Contacter le responsable de la rubrique Accueil