Remarque
L’accès à cette page requiert une autorisation. Vous pouvez essayer de vous connecter ou de modifier des répertoires.
L’accès à cette page requiert une autorisation. Vous pouvez essayer de modifier des répertoires.
Par Steve Smith
Les tests unitaires impliquent le test d’une partie d’une application de façon isolée par rapport à son infrastructure et à ses dépendances. Lors du test de la logique d’un contrôleur, seuls les contenus d’une action sont testés, et non pas le comportement de ses dépendances ou du framework lui-même.
Contrôleurs de test unitaire
Configurez des tests unitaires d’actions de contrôleur pour qu’ils s’attachent uniquement au comportement du contrôleur. Un test unitaire de contrôleur évite des scénarios, tels que les filtres, le routage et la liaison de données. Les tests couvrant les interactions entre les composants qui répondent collectivement à une requête sont gérés par des tests d’intégration. Pour plus d’informations sur les tests d’intégration, consultez Tests d’intégration dans ASP.NET Core.
Si vous écrivez des routes et des filtres personnalisés, testez-les de manière isolée avec des tests unitaires plutôt que dans le cadre de tests exécutés sur une action de contrôleur spécifique.
Pour illustrer les tests unitaires de contrôleur, examinez de plus près le contrôleur suivant dans l’exemple d’application.
Affichez ou téléchargez l’exemple de code (procédure de téléchargement)
Le contrôleur Home affiche une liste de sessions de brainstorming et permet la création de nouvelles sessions avec une requête POST :
public class HomeController : Controller
{
private readonly IBrainstormSessionRepository _sessionRepository;
public HomeController(IBrainstormSessionRepository sessionRepository)
{
_sessionRepository = sessionRepository;
}
public async Task<IActionResult> Index()
{
var sessionList = await _sessionRepository.ListAsync();
var model = sessionList.Select(session => new StormSessionViewModel()
{
Id = session.Id,
DateCreated = session.DateCreated,
Name = session.Name,
IdeaCount = session.Ideas.Count
});
return View(model);
}
public class NewSessionModel
{
[Required]
public string SessionName { get; set; }
}
[HttpPost]
public async Task<IActionResult> Index(NewSessionModel model)
{
if (!ModelState.IsValid)
{
return BadRequest(ModelState);
}
else
{
await _sessionRepository.AddAsync(new BrainstormSession()
{
DateCreated = DateTimeOffset.Now,
Name = model.SessionName
});
}
return RedirectToAction(actionName: nameof(Index));
}
}
Le contrôleur précédent :
- Suit le Principe des dépendances explicites.
- Attend que l’injection de dépendance fournisse une instance de
IBrainstormSessionRepository. - Peut être testé avec un service
IBrainstormSessionRepositoryfictif au moyen d’un framework d’objets fictifs, tel que Moq. Un objet fictif est un objet fabriqué avec un ensemble prédéterminé de comportements de propriété et de méthode utilisés pour les tests. Pour plus d’informations, consultez Introduction aux tests d’intégration.
La méthode HTTP GET Index n’a pas de boucle ni de branchement, et elle appelle seulement une méthode. Le test unitaire pour cette action :
- Simule le service
IBrainstormSessionRepositoryà l’aide de la méthodeGetTestSessions.GetTestSessionscrée deux sessions de brainstorming fictives avec des dates et des noms de session. - Exécute la méthode
Index. - Fait des assertions sur le résultat retourné par la méthode :
- Un ViewResult est retourné.
- Le ViewDataDictionary.Model est un
StormSessionViewModel. - Deux sessions de brainstorming sont stockées dans le
ViewDataDictionary.Model.
[Fact]
public async Task Index_ReturnsAViewResult_WithAListOfBrainstormSessions()
{
// Arrange
var mockRepo = new Mock<IBrainstormSessionRepository>();
mockRepo.Setup(repo => repo.ListAsync())
.ReturnsAsync(GetTestSessions());
var controller = new HomeController(mockRepo.Object);
// Act
var result = await controller.Index();
// Assert
var viewResult = Assert.IsType<ViewResult>(result);
var model = Assert.IsAssignableFrom<IEnumerable<StormSessionViewModel>>(
viewResult.ViewData.Model);
Assert.Equal(2, model.Count());
}
private List<BrainstormSession> GetTestSessions()
{
var sessions = new List<BrainstormSession>();
sessions.Add(new BrainstormSession()
{
DateCreated = new DateTime(2016, 7, 2),
Id = 1,
Name = "Test One"
});
sessions.Add(new BrainstormSession()
{
DateCreated = new DateTime(2016, 7, 1),
Id = 2,
Name = "Test Two"
});
return sessions;
}
Les tests de la méthode Home du contrôleur HTTP POST Index s’assurent que :
- Quand ModelState.IsValid est
false, la méthode d’action retourne un ViewResult avec les données appropriées. - Lorsque
ModelState.IsValidesttrue:- La méthode
Addsur le dépôt est appelée. - Un RedirectToActionResult est retourné avec les arguments corrects.
- La méthode
Un état de modèle non valide est testé en ajoutant des erreurs avec AddModelError, comme le montre le premier test ci-dessous :
[Fact]
public async Task IndexPost_ReturnsBadRequestResult_WhenModelStateIsInvalid()
{
// Arrange
var mockRepo = new Mock<IBrainstormSessionRepository>();
mockRepo.Setup(repo => repo.ListAsync())
.ReturnsAsync(GetTestSessions());
var controller = new HomeController(mockRepo.Object);
controller.ModelState.AddModelError("SessionName", "Required");
var newSession = new HomeController.NewSessionModel();
// Act
var result = await controller.Index(newSession);
// Assert
var badRequestResult = Assert.IsType<BadRequestObjectResult>(result);
Assert.IsType<SerializableError>(badRequestResult.Value);
}
[Fact]
public async Task IndexPost_ReturnsARedirectAndAddsSession_WhenModelStateIsValid()
{
// Arrange
var mockRepo = new Mock<IBrainstormSessionRepository>();
mockRepo.Setup(repo => repo.AddAsync(It.IsAny<BrainstormSession>()))
.Returns(Task.CompletedTask)
.Verifiable();
var controller = new HomeController(mockRepo.Object);
var newSession = new HomeController.NewSessionModel()
{
SessionName = "Test Name"
};
// Act
var result = await controller.Index(newSession);
// Assert
var redirectToActionResult = Assert.IsType<RedirectToActionResult>(result);
Assert.Null(redirectToActionResult.ControllerName);
Assert.Equal("Index", redirectToActionResult.ActionName);
mockRepo.Verify();
}
Lorsque ModelState n’est pas valide, le même ViewResult est retourné, comme pour une requête GET. Le test ne tente pas de passer un modèle non valide. Le passage d’un modèle non valide n’est pas une approche admise, car la liaison de modèle n’est pas en cours d’exécution (même si un test d’intégration utilise bien la liaison de modèle). Dans ce cas, la liaison de modèle n’est pas testée. Ces tests unitaires testent seulement le code dans la méthode d’action.
Le second test vérifie cela quand le ModelState est valide :
- Un nouveau
BrainstormSessionest ajouté (par le biais du référentiel). - La méthode retourne un
RedirectToActionResultavec les propriétés attendues.
Les appels fictifs qui ne sont pas appelés sont normalement ignorés, mais l’appel de Verifiable à la fin de l’appel de configuration autorise la validation fictive dans le test. Ceci est effectué avec l’appel à mockRepo.Verify, qui échoue au test si la méthode attendue n’a pas été appelée.
Note
La bibliothèque Moq utilisée dans cet exemple permet la combinaison d’éléments fictifs vérifiables (ou « stricts ») avec des éléments fictifs non vérifiables (également appelés stubs ou éléments « lâches »). Découvrez plus d’informations sur la personnalisation des éléments fictifs avec Moq.
Dans l’exemple d’application, SessionController affiche des informations relatives à une session de brainstorming. Le contrôleur inclut la logique pour traiter des valeurs id non valides (il existe deux scénarios return dans l’exemple suivant pour couvrir ces scénarios). La dernière instruction return retourne un nouveau StormSessionViewModel dans l’affichage (Controllers/SessionController.cs) :
public class SessionController : Controller
{
private readonly IBrainstormSessionRepository _sessionRepository;
public SessionController(IBrainstormSessionRepository sessionRepository)
{
_sessionRepository = sessionRepository;
}
public async Task<IActionResult> Index(int? id)
{
if (!id.HasValue)
{
return RedirectToAction(actionName: nameof(Index),
controllerName: "Home");
}
var session = await _sessionRepository.GetByIdAsync(id.Value);
if (session == null)
{
return Content("Session not found.");
}
var viewModel = new StormSessionViewModel()
{
DateCreated = session.DateCreated,
Name = session.Name,
Id = session.Id
};
return View(viewModel);
}
}
Les tests unitaires comportent un test pour chaque scénario return dans l’action Index du contrôleur Session :
[Fact]
public async Task IndexReturnsARedirectToIndexHomeWhenIdIsNull()
{
// Arrange
var controller = new SessionController(sessionRepository: null);
// Act
var result = await controller.Index(id: null);
// Assert
var redirectToActionResult =
Assert.IsType<RedirectToActionResult>(result);
Assert.Equal("Home", redirectToActionResult.ControllerName);
Assert.Equal("Index", redirectToActionResult.ActionName);
}
[Fact]
public async Task IndexReturnsContentWithSessionNotFoundWhenSessionNotFound()
{
// Arrange
int testSessionId = 1;
var mockRepo = new Mock<IBrainstormSessionRepository>();
mockRepo.Setup(repo => repo.GetByIdAsync(testSessionId))
.ReturnsAsync((BrainstormSession)null);
var controller = new SessionController(mockRepo.Object);
// Act
var result = await controller.Index(testSessionId);
// Assert
var contentResult = Assert.IsType<ContentResult>(result);
Assert.Equal("Session not found.", contentResult.Content);
}
[Fact]
public async Task IndexReturnsViewResultWithStormSessionViewModel()
{
// Arrange
int testSessionId = 1;
var mockRepo = new Mock<IBrainstormSessionRepository>();
mockRepo.Setup(repo => repo.GetByIdAsync(testSessionId))
.ReturnsAsync(GetTestSessions().FirstOrDefault(
s => s.Id == testSessionId));
var controller = new SessionController(mockRepo.Object);
// Act
var result = await controller.Index(testSessionId);
// Assert
var viewResult = Assert.IsType<ViewResult>(result);
var model = Assert.IsType<StormSessionViewModel>(
viewResult.ViewData.Model);
Assert.Equal("Test One", model.Name);
Assert.Equal(2, model.DateCreated.Day);
Assert.Equal(testSessionId, model.Id);
}
En passant au contrôleur Ideas, l’application expose des fonctionnalités, comme une API web sur la route api/ideas :
- Une liste d’idées (
IdeaDTO) associées à une session de brainstorming est retournée par la méthodeForSession. - La méthode
Createajoute de nouvelles idées à une session.
[HttpGet("forsession/{sessionId}")]
public async Task<IActionResult> ForSession(int sessionId)
{
var session = await _sessionRepository.GetByIdAsync(sessionId);
if (session == null)
{
return NotFound(sessionId);
}
var result = session.Ideas.Select(idea => new IdeaDTO()
{
Id = idea.Id,
Name = idea.Name,
Description = idea.Description,
DateCreated = idea.DateCreated
}).ToList();
return Ok(result);
}
[HttpPost("create")]
public async Task<IActionResult> Create([FromBody]NewIdeaModel model)
{
if (!ModelState.IsValid)
{
return BadRequest(ModelState);
}
var session = await _sessionRepository.GetByIdAsync(model.SessionId);
if (session == null)
{
return NotFound(model.SessionId);
}
var idea = new Idea()
{
DateCreated = DateTimeOffset.Now,
Description = model.Description,
Name = model.Name
};
session.AddIdea(idea);
await _sessionRepository.UpdateAsync(session);
return Ok(session);
}
Évitez de retourner des entités de domaine métier directement via des appels d’API. Entités de domaine :
- Incluent souvent plus de données que ce dont a besoin le client.
- Couplent inutilement le modèle de domaine interne de l’application à l’API exposée publiquement.
Le mappage entre des entités de domaine et les types retournés au client peut être effectué :
- Manuellement avec un
SelectLINQ, comme l’utilise l’exemple d’application. Pour plus d’informations, consultez LINQ (Language-Integrated Query). - Automatiquement avec une bibliothèque, telle que AutoMapper.
Ensuite, l’exemple d’application décrit des tests unitaires pour les méthodes d’API Create et ForSession du contrôleur Ideas.
L’exemple d’application contient deux tests ForSession. Le premier test détermine si ForSession retourne un NotFoundObjectResult (HTTP Non trouvé) pour une session non valide :
[Fact]
public async Task ForSession_ReturnsHttpNotFound_ForInvalidSession()
{
// Arrange
int testSessionId = 123;
var mockRepo = new Mock<IBrainstormSessionRepository>();
mockRepo.Setup(repo => repo.GetByIdAsync(testSessionId))
.ReturnsAsync((BrainstormSession)null);
var controller = new IdeasController(mockRepo.Object);
// Act
var result = await controller.ForSession(testSessionId);
// Assert
var notFoundObjectResult = Assert.IsType<NotFoundObjectResult>(result);
Assert.Equal(testSessionId, notFoundObjectResult.Value);
}
La deuxième test ForSession détermine si ForSession retourne une liste d’idées de session (<List<IdeaDTO>>) pour une session valide. Les vérifications contrôlent également la première idée pour confirmer que sa propriété Name est correcte :
[Fact]
public async Task ForSession_ReturnsIdeasForSession()
{
// Arrange
int testSessionId = 123;
var mockRepo = new Mock<IBrainstormSessionRepository>();
mockRepo.Setup(repo => repo.GetByIdAsync(testSessionId))
.ReturnsAsync(GetTestSession());
var controller = new IdeasController(mockRepo.Object);
// Act
var result = await controller.ForSession(testSessionId);
// Assert
var okResult = Assert.IsType<OkObjectResult>(result);
var returnValue = Assert.IsType<List<IdeaDTO>>(okResult.Value);
var idea = returnValue.FirstOrDefault();
Assert.Equal("One", idea.Name);
}
Pour tester le comportement de la méthode Create quand le ModelState n’est pas valide, l’exemple d’application ajoute une erreur de modèle au contrôleur dans le cadre de ce test. N’essayez pas de tester la validation de modèle ou la liaison de modèle dans des tests unitaires. Testez simplement le comportement de votre méthode d’action quand elle est confrontée à un ModelState non valide :
[Fact]
public async Task Create_ReturnsBadRequest_GivenInvalidModel()
{
// Arrange & Act
var mockRepo = new Mock<IBrainstormSessionRepository>();
var controller = new IdeasController(mockRepo.Object);
controller.ModelState.AddModelError("error", "some error");
// Act
var result = await controller.Create(model: null);
// Assert
Assert.IsType<BadRequestObjectResult>(result);
}
Le deuxième test de Create dépend du retour de null par le dépôt, le dépôt fictif est donc configuré pour retourner null. Il est inutile de créer une base de données de test (dans la mémoire ou ailleurs) et de construire une requête qui retourne ce résultat. Le test peut être effectué en une seule instruction, comme le montre l’exemple de code :
[Fact]
public async Task Create_ReturnsHttpNotFound_ForInvalidSession()
{
// Arrange
int testSessionId = 123;
var mockRepo = new Mock<IBrainstormSessionRepository>();
mockRepo.Setup(repo => repo.GetByIdAsync(testSessionId))
.ReturnsAsync((BrainstormSession)null);
var controller = new IdeasController(mockRepo.Object);
// Act
var result = await controller.Create(new NewIdeaModel());
// Assert
Assert.IsType<NotFoundObjectResult>(result);
}
Le troisième test Create, Create_ReturnsNewlyCreatedIdeaForSession, vérifie que la méthode UpdateAsync du dépôt est appelée. L’élément fictif est appelé avec Verifiable, puis la méthode Verify du dépôt fictif est appelée pour confirmer que la méthode vérifiable est exécutée. La garantie que la méthode UpdateAsync a enregistré les données ne relève pas de la responsabilité du test unitaire. Cette vérification peut être effectuée au moyen d’un test d’intégration.
[Fact]
public async Task Create_ReturnsNewlyCreatedIdeaForSession()
{
// Arrange
int testSessionId = 123;
string testName = "test name";
string testDescription = "test description";
var testSession = GetTestSession();
var mockRepo = new Mock<IBrainstormSessionRepository>();
mockRepo.Setup(repo => repo.GetByIdAsync(testSessionId))
.ReturnsAsync(testSession);
var controller = new IdeasController(mockRepo.Object);
var newIdea = new NewIdeaModel()
{
Description = testDescription,
Name = testName,
SessionId = testSessionId
};
mockRepo.Setup(repo => repo.UpdateAsync(testSession))
.Returns(Task.CompletedTask)
.Verifiable();
// Act
var result = await controller.Create(newIdea);
// Assert
var okResult = Assert.IsType<OkObjectResult>(result);
var returnSession = Assert.IsType<BrainstormSession>(okResult.Value);
mockRepo.Verify();
Assert.Equal(2, returnSession.Ideas.Count());
Assert.Equal(testName, returnSession.Ideas.LastOrDefault().Name);
Assert.Equal(testDescription, returnSession.Ideas.LastOrDefault().Description);
}
Tester ActionResult<T>
ActionResult<T> (ActionResult<TValue>) peut retourner un type dérivant de ActionResult ou retourner un type spécifique.
L’exemple d’application comprend une méthode qui retourne un List<IdeaDTO> pour un id de session donné. Si l’id de session n’existe pas, le contrôleur retourne NotFound :
[HttpGet("forsessionactionresult/{sessionId}")]
[ProducesResponseType(200)]
[ProducesResponseType(404)]
public async Task<ActionResult<List<IdeaDTO>>> ForSessionActionResult(int sessionId)
{
var session = await _sessionRepository.GetByIdAsync(sessionId);
if (session == null)
{
return NotFound(sessionId);
}
var result = session.Ideas.Select(idea => new IdeaDTO()
{
Id = idea.Id,
Name = idea.Name,
Description = idea.Description,
DateCreated = idea.DateCreated
}).ToList();
return result;
}
Deux tests du contrôleur ForSessionActionResult sont inclus dans ApiIdeasControllerTests.
Le premier test confirme que le contrôleur retourne un ActionResult, et non une liste inexistante d’idées pour un id de session inexistant :
- Le type
ActionResultestActionResult<List<IdeaDTO>>. - Le Result est un NotFoundObjectResult.
[Fact]
public async Task ForSessionActionResult_ReturnsNotFoundObjectResultForNonexistentSession()
{
// Arrange
var mockRepo = new Mock<IBrainstormSessionRepository>();
var controller = new IdeasController(mockRepo.Object);
var nonExistentSessionId = 999;
// Act
var result = await controller.ForSessionActionResult(nonExistentSessionId);
// Assert
var actionResult = Assert.IsType<ActionResult<List<IdeaDTO>>>(result);
Assert.IsType<NotFoundObjectResult>(actionResult.Result);
}
Pour un id de session valide, le deuxième test confirme que la méthode retourne :
- Un
ActionResultavec un typeList<IdeaDTO>. -
ActionResult<T>.Value est un type
List<IdeaDTO>. - Le premier élément dans la liste est une idée valide correspondant à l’idée stockée dans la session fictive (obtenu en appelant
GetTestSession).
[Fact]
public async Task ForSessionActionResult_ReturnsIdeasForSession()
{
// Arrange
int testSessionId = 123;
var mockRepo = new Mock<IBrainstormSessionRepository>();
mockRepo.Setup(repo => repo.GetByIdAsync(testSessionId))
.ReturnsAsync(GetTestSession());
var controller = new IdeasController(mockRepo.Object);
// Act
var result = await controller.ForSessionActionResult(testSessionId);
// Assert
var actionResult = Assert.IsType<ActionResult<List<IdeaDTO>>>(result);
var returnValue = Assert.IsType<List<IdeaDTO>>(actionResult.Value);
var idea = returnValue.FirstOrDefault();
Assert.Equal("One", idea.Name);
}
L’exemple d’application comporte également une méthode pour créer un nouveau Idea pour une session donnée. Le contrôleur retourne :
- BadRequest pour un modèle non valide.
- NotFound si la session n’existe pas.
- CreatedAtAction lorsque la session est mise à jour avec la nouvelle idée.
[HttpPost("createactionresult")]
[ProducesResponseType(201)]
[ProducesResponseType(400)]
[ProducesResponseType(404)]
public async Task<ActionResult<BrainstormSession>> CreateActionResult([FromBody]NewIdeaModel model)
{
if (!ModelState.IsValid)
{
return BadRequest(ModelState);
}
var session = await _sessionRepository.GetByIdAsync(model.SessionId);
if (session == null)
{
return NotFound(model.SessionId);
}
var idea = new Idea()
{
DateCreated = DateTimeOffset.Now,
Description = model.Description,
Name = model.Name
};
session.AddIdea(idea);
await _sessionRepository.UpdateAsync(session);
return CreatedAtAction(nameof(CreateActionResult), new { id = session.Id }, session);
}
Trois tests de CreateActionResult sont inclus dans ApiIdeasControllerTests.
Le premier test confirme qu’un BadRequest est retourné pour un modèle non valide.
[Fact]
public async Task CreateActionResult_ReturnsBadRequest_GivenInvalidModel()
{
// Arrange & Act
var mockRepo = new Mock<IBrainstormSessionRepository>();
var controller = new IdeasController(mockRepo.Object);
controller.ModelState.AddModelError("error", "some error");
// Act
var result = await controller.CreateActionResult(model: null);
// Assert
var actionResult = Assert.IsType<ActionResult<BrainstormSession>>(result);
Assert.IsType<BadRequestObjectResult>(actionResult.Result);
}
Le deuxième test vérifie qu’un NotFound est retourné si la session n’existe pas.
[Fact]
public async Task CreateActionResult_ReturnsNotFoundObjectResultForNonexistentSession()
{
// Arrange
var nonExistentSessionId = 999;
string testName = "test name";
string testDescription = "test description";
var mockRepo = new Mock<IBrainstormSessionRepository>();
var controller = new IdeasController(mockRepo.Object);
var newIdea = new NewIdeaModel()
{
Description = testDescription,
Name = testName,
SessionId = nonExistentSessionId
};
// Act
var result = await controller.CreateActionResult(newIdea);
// Assert
var actionResult = Assert.IsType<ActionResult<BrainstormSession>>(result);
Assert.IsType<NotFoundObjectResult>(actionResult.Result);
}
Pour un id de session valide, le dernier test confirme que :
- La méthode retourne un
ActionResultavec un typeBrainstormSession. -
ActionResult<T>.Result est un CreatedAtActionResult.
CreatedAtActionResultest analogue à une réponse Créée 201 avec un en-têteLocation. -
ActionResult<T>.Value est un type
BrainstormSession. - L’appel fictif pour mettre à jour la session,
UpdateAsync(testSession), a été appelé. L’appel de la méthodeVerifiableest contrôlé en exécutantmockRepo.Verify()dans les assertions. - Deux objets
Ideasont retournés pour la session. - Le dernier élément (
Ideaajouté par l’appel fictif àUpdateAsync) correspond ànewIdeaajouté à la session dans le test.
[Fact]
public async Task CreateActionResult_ReturnsNewlyCreatedIdeaForSession()
{
// Arrange
int testSessionId = 123;
string testName = "test name";
string testDescription = "test description";
var testSession = GetTestSession();
var mockRepo = new Mock<IBrainstormSessionRepository>();
mockRepo.Setup(repo => repo.GetByIdAsync(testSessionId))
.ReturnsAsync(testSession);
var controller = new IdeasController(mockRepo.Object);
var newIdea = new NewIdeaModel()
{
Description = testDescription,
Name = testName,
SessionId = testSessionId
};
mockRepo.Setup(repo => repo.UpdateAsync(testSession))
.Returns(Task.CompletedTask)
.Verifiable();
// Act
var result = await controller.CreateActionResult(newIdea);
// Assert
var actionResult = Assert.IsType<ActionResult<BrainstormSession>>(result);
var createdAtActionResult = Assert.IsType<CreatedAtActionResult>(actionResult.Result);
var returnValue = Assert.IsType<BrainstormSession>(createdAtActionResult.Value);
mockRepo.Verify();
Assert.Equal(2, returnValue.Ideas.Count());
Assert.Equal(testName, returnValue.Ideas.LastOrDefault().Name);
Assert.Equal(testDescription, returnValue.Ideas.LastOrDefault().Description);
}
Les contrôleurs jouent un rôle essentiel dans une application ASP.NET Core MVC. En tant que tel, vous devez être sûr qu’ils se comportent comme prévu. Les tests automatisés peuvent détecter des erreurs avant le déploiement de l’application dans un environnement de production.
Affichez ou téléchargez l’exemple de code (procédure de téléchargement)
Tests unitaires de la logique de contrôleur
Les tests unitaires impliquent le test d’une partie d’une application de façon isolée par rapport à son infrastructure et à ses dépendances. Lors du test de la logique d’un contrôleur, seuls les contenus d’une action sont testés, et non pas le comportement de ses dépendances ou du framework lui-même.
Configurez des tests unitaires d’actions de contrôleur pour qu’ils s’attachent uniquement au comportement du contrôleur. Un test unitaire de contrôleur évite des scénarios, tels que les filtres, le routage et la liaison de données. Les tests couvrant les interactions entre les composants qui répondent collectivement à une requête sont gérés par des tests d’intégration. Pour plus d’informations sur les tests d’intégration, consultez Tests d’intégration dans ASP.NET Core.
Si vous écrivez des routes et des filtres personnalisés, testez-les de manière isolée avec des tests unitaires plutôt que dans le cadre de tests exécutés sur une action de contrôleur spécifique.
Pour illustrer les tests unitaires de contrôleur, examinez de plus près le contrôleur suivant dans l’exemple d’application. Le contrôleur Home affiche une liste de sessions de brainstorming et permet la création de nouvelles sessions avec une requête POST :
public class HomeController : Controller
{
private readonly IBrainstormSessionRepository _sessionRepository;
public HomeController(IBrainstormSessionRepository sessionRepository)
{
_sessionRepository = sessionRepository;
}
public async Task<IActionResult> Index()
{
var sessionList = await _sessionRepository.ListAsync();
var model = sessionList.Select(session => new StormSessionViewModel()
{
Id = session.Id,
DateCreated = session.DateCreated,
Name = session.Name,
IdeaCount = session.Ideas.Count
});
return View(model);
}
public class NewSessionModel
{
[Required]
public string SessionName { get; set; }
}
[HttpPost]
public async Task<IActionResult> Index(NewSessionModel model)
{
if (!ModelState.IsValid)
{
return BadRequest(ModelState);
}
else
{
await _sessionRepository.AddAsync(new BrainstormSession()
{
DateCreated = DateTimeOffset.Now,
Name = model.SessionName
});
}
return RedirectToAction(actionName: nameof(Index));
}
}
Le contrôleur précédent :
- Suit le Principe des dépendances explicites.
- Attend que l’injection de dépendance fournisse une instance de
IBrainstormSessionRepository. - Peut être testé avec un service
IBrainstormSessionRepositoryfictif au moyen d’un framework d’objets fictifs, tel que Moq. Un objet fictif est un objet fabriqué avec un ensemble prédéterminé de comportements de propriété et de méthode utilisés pour les tests. Pour plus d’informations, consultez Introduction aux tests d’intégration.
La méthode HTTP GET Index n’a pas de boucle ni de branchement, et elle appelle seulement une méthode. Le test unitaire pour cette action :
- Simule le service
IBrainstormSessionRepositoryà l’aide de la méthodeGetTestSessions.GetTestSessionscrée deux sessions de brainstorming fictives avec des dates et des noms de session. - Exécute la méthode
Index. - Fait des assertions sur le résultat retourné par la méthode :
- Un ViewResult est retourné.
- Le ViewDataDictionary.Model est un
StormSessionViewModel. - Deux sessions de brainstorming sont stockées dans le
ViewDataDictionary.Model.
[Fact]
public async Task Index_ReturnsAViewResult_WithAListOfBrainstormSessions()
{
// Arrange
var mockRepo = new Mock<IBrainstormSessionRepository>();
mockRepo.Setup(repo => repo.ListAsync())
.ReturnsAsync(GetTestSessions());
var controller = new HomeController(mockRepo.Object);
// Act
var result = await controller.Index();
// Assert
var viewResult = Assert.IsType<ViewResult>(result);
var model = Assert.IsAssignableFrom<IEnumerable<StormSessionViewModel>>(
viewResult.ViewData.Model);
Assert.Equal(2, model.Count());
}
private List<BrainstormSession> GetTestSessions()
{
var sessions = new List<BrainstormSession>();
sessions.Add(new BrainstormSession()
{
DateCreated = new DateTime(2016, 7, 2),
Id = 1,
Name = "Test One"
});
sessions.Add(new BrainstormSession()
{
DateCreated = new DateTime(2016, 7, 1),
Id = 2,
Name = "Test Two"
});
return sessions;
}
Les tests de la méthode Home du contrôleur HTTP POST Index s’assurent que :
- Quand ModelState.IsValid est
false, la méthode d’action retourne un ViewResult avec les données appropriées. - Lorsque
ModelState.IsValidesttrue:- La méthode
Addsur le dépôt est appelée. - Un RedirectToActionResult est retourné avec les arguments corrects.
- La méthode
Un état de modèle non valide est testé en ajoutant des erreurs avec AddModelError, comme le montre le premier test ci-dessous :
[Fact]
public async Task IndexPost_ReturnsBadRequestResult_WhenModelStateIsInvalid()
{
// Arrange
var mockRepo = new Mock<IBrainstormSessionRepository>();
mockRepo.Setup(repo => repo.ListAsync())
.ReturnsAsync(GetTestSessions());
var controller = new HomeController(mockRepo.Object);
controller.ModelState.AddModelError("SessionName", "Required");
var newSession = new HomeController.NewSessionModel();
// Act
var result = await controller.Index(newSession);
// Assert
var badRequestResult = Assert.IsType<BadRequestObjectResult>(result);
Assert.IsType<SerializableError>(badRequestResult.Value);
}
[Fact]
public async Task IndexPost_ReturnsARedirectAndAddsSession_WhenModelStateIsValid()
{
// Arrange
var mockRepo = new Mock<IBrainstormSessionRepository>();
mockRepo.Setup(repo => repo.AddAsync(It.IsAny<BrainstormSession>()))
.Returns(Task.CompletedTask)
.Verifiable();
var controller = new HomeController(mockRepo.Object);
var newSession = new HomeController.NewSessionModel()
{
SessionName = "Test Name"
};
// Act
var result = await controller.Index(newSession);
// Assert
var redirectToActionResult = Assert.IsType<RedirectToActionResult>(result);
Assert.Null(redirectToActionResult.ControllerName);
Assert.Equal("Index", redirectToActionResult.ActionName);
mockRepo.Verify();
}
Lorsque ModelState n’est pas valide, le même ViewResult est retourné, comme pour une requête GET. Le test ne tente pas de passer un modèle non valide. Le passage d’un modèle non valide n’est pas une approche admise, car la liaison de modèle n’est pas en cours d’exécution (même si un test d’intégration utilise bien la liaison de modèle). Dans ce cas, la liaison de modèle n’est pas testée. Ces tests unitaires testent seulement le code dans la méthode d’action.
Le second test vérifie cela quand le ModelState est valide :
- Un nouveau
BrainstormSessionest ajouté (par le biais du référentiel). - La méthode retourne un
RedirectToActionResultavec les propriétés attendues.
Les appels fictifs qui ne sont pas appelés sont normalement ignorés, mais l’appel de Verifiable à la fin de l’appel de configuration autorise la validation fictive dans le test. Ceci est effectué avec l’appel à mockRepo.Verify, qui échoue au test si la méthode attendue n’a pas été appelée.
Note
La bibliothèque Moq utilisée dans cet exemple permet la combinaison d’éléments fictifs vérifiables (ou « stricts ») avec des éléments fictifs non vérifiables (également appelés stubs ou éléments « lâches »). Découvrez plus d’informations sur la personnalisation des éléments fictifs avec Moq.
Dans l’exemple d’application, SessionController affiche des informations relatives à une session de brainstorming. Le contrôleur inclut la logique pour traiter des valeurs id non valides (il existe deux scénarios return dans l’exemple suivant pour couvrir ces scénarios). La dernière instruction return retourne un nouveau StormSessionViewModel dans l’affichage (Controllers/SessionController.cs) :
public class SessionController : Controller
{
private readonly IBrainstormSessionRepository _sessionRepository;
public SessionController(IBrainstormSessionRepository sessionRepository)
{
_sessionRepository = sessionRepository;
}
public async Task<IActionResult> Index(int? id)
{
if (!id.HasValue)
{
return RedirectToAction(actionName: nameof(Index),
controllerName: "Home");
}
var session = await _sessionRepository.GetByIdAsync(id.Value);
if (session == null)
{
return Content("Session not found.");
}
var viewModel = new StormSessionViewModel()
{
DateCreated = session.DateCreated,
Name = session.Name,
Id = session.Id
};
return View(viewModel);
}
}
Les tests unitaires comportent un test pour chaque scénario return dans l’action Index du contrôleur Session :
[Fact]
public async Task IndexReturnsARedirectToIndexHomeWhenIdIsNull()
{
// Arrange
var controller = new SessionController(sessionRepository: null);
// Act
var result = await controller.Index(id: null);
// Assert
var redirectToActionResult =
Assert.IsType<RedirectToActionResult>(result);
Assert.Equal("Home", redirectToActionResult.ControllerName);
Assert.Equal("Index", redirectToActionResult.ActionName);
}
[Fact]
public async Task IndexReturnsContentWithSessionNotFoundWhenSessionNotFound()
{
// Arrange
int testSessionId = 1;
var mockRepo = new Mock<IBrainstormSessionRepository>();
mockRepo.Setup(repo => repo.GetByIdAsync(testSessionId))
.ReturnsAsync((BrainstormSession)null);
var controller = new SessionController(mockRepo.Object);
// Act
var result = await controller.Index(testSessionId);
// Assert
var contentResult = Assert.IsType<ContentResult>(result);
Assert.Equal("Session not found.", contentResult.Content);
}
[Fact]
public async Task IndexReturnsViewResultWithStormSessionViewModel()
{
// Arrange
int testSessionId = 1;
var mockRepo = new Mock<IBrainstormSessionRepository>();
mockRepo.Setup(repo => repo.GetByIdAsync(testSessionId))
.ReturnsAsync(GetTestSessions().FirstOrDefault(
s => s.Id == testSessionId));
var controller = new SessionController(mockRepo.Object);
// Act
var result = await controller.Index(testSessionId);
// Assert
var viewResult = Assert.IsType<ViewResult>(result);
var model = Assert.IsType<StormSessionViewModel>(
viewResult.ViewData.Model);
Assert.Equal("Test One", model.Name);
Assert.Equal(2, model.DateCreated.Day);
Assert.Equal(testSessionId, model.Id);
}
En passant au contrôleur Ideas, l’application expose des fonctionnalités, comme une API web sur la route api/ideas :
- Une liste d’idées (
IdeaDTO) associées à une session de brainstorming est retournée par la méthodeForSession. - La méthode
Createajoute de nouvelles idées à une session.
[HttpGet("forsession/{sessionId}")]
public async Task<IActionResult> ForSession(int sessionId)
{
var session = await _sessionRepository.GetByIdAsync(sessionId);
if (session == null)
{
return NotFound(sessionId);
}
var result = session.Ideas.Select(idea => new IdeaDTO()
{
Id = idea.Id,
Name = idea.Name,
Description = idea.Description,
DateCreated = idea.DateCreated
}).ToList();
return Ok(result);
}
[HttpPost("create")]
public async Task<IActionResult> Create([FromBody]NewIdeaModel model)
{
if (!ModelState.IsValid)
{
return BadRequest(ModelState);
}
var session = await _sessionRepository.GetByIdAsync(model.SessionId);
if (session == null)
{
return NotFound(model.SessionId);
}
var idea = new Idea()
{
DateCreated = DateTimeOffset.Now,
Description = model.Description,
Name = model.Name
};
session.AddIdea(idea);
await _sessionRepository.UpdateAsync(session);
return Ok(session);
}
Évitez de retourner des entités de domaine métier directement via des appels d’API. Entités de domaine :
- Incluent souvent plus de données que ce dont a besoin le client.
- Couplent inutilement le modèle de domaine interne de l’application à l’API exposée publiquement.
Le mappage entre des entités de domaine et les types retournés au client peut être effectué :
- Manuellement avec un
SelectLINQ, comme l’utilise l’exemple d’application. Pour plus d’informations, consultez LINQ (Language-Integrated Query). - Automatiquement avec une bibliothèque, telle que AutoMapper.
Ensuite, l’exemple d’application décrit des tests unitaires pour les méthodes d’API Create et ForSession du contrôleur Ideas.
L’exemple d’application contient deux tests ForSession. Le premier test détermine si ForSession retourne un NotFoundObjectResult (HTTP Non trouvé) pour une session non valide :
[Fact]
public async Task ForSession_ReturnsHttpNotFound_ForInvalidSession()
{
// Arrange
int testSessionId = 123;
var mockRepo = new Mock<IBrainstormSessionRepository>();
mockRepo.Setup(repo => repo.GetByIdAsync(testSessionId))
.ReturnsAsync((BrainstormSession)null);
var controller = new IdeasController(mockRepo.Object);
// Act
var result = await controller.ForSession(testSessionId);
// Assert
var notFoundObjectResult = Assert.IsType<NotFoundObjectResult>(result);
Assert.Equal(testSessionId, notFoundObjectResult.Value);
}
La deuxième test ForSession détermine si ForSession retourne une liste d’idées de session (<List<IdeaDTO>>) pour une session valide. Les vérifications contrôlent également la première idée pour confirmer que sa propriété Name est correcte :
[Fact]
public async Task ForSession_ReturnsIdeasForSession()
{
// Arrange
int testSessionId = 123;
var mockRepo = new Mock<IBrainstormSessionRepository>();
mockRepo.Setup(repo => repo.GetByIdAsync(testSessionId))
.ReturnsAsync(GetTestSession());
var controller = new IdeasController(mockRepo.Object);
// Act
var result = await controller.ForSession(testSessionId);
// Assert
var okResult = Assert.IsType<OkObjectResult>(result);
var returnValue = Assert.IsType<List<IdeaDTO>>(okResult.Value);
var idea = returnValue.FirstOrDefault();
Assert.Equal("One", idea.Name);
}
Pour tester le comportement de la méthode Create quand le ModelState n’est pas valide, l’exemple d’application ajoute une erreur de modèle au contrôleur dans le cadre de ce test. N’essayez pas de tester la validation de modèle ou la liaison de modèle dans des tests unitaires. Testez simplement le comportement de votre méthode d’action quand elle est confrontée à un ModelState non valide :
[Fact]
public async Task Create_ReturnsBadRequest_GivenInvalidModel()
{
// Arrange & Act
var mockRepo = new Mock<IBrainstormSessionRepository>();
var controller = new IdeasController(mockRepo.Object);
controller.ModelState.AddModelError("error", "some error");
// Act
var result = await controller.Create(model: null);
// Assert
Assert.IsType<BadRequestObjectResult>(result);
}
Le deuxième test de Create dépend du retour de null par le dépôt, le dépôt fictif est donc configuré pour retourner null. Il est inutile de créer une base de données de test (dans la mémoire ou ailleurs) et de construire une requête qui retourne ce résultat. Le test peut être effectué en une seule instruction, comme le montre l’exemple de code :
[Fact]
public async Task Create_ReturnsHttpNotFound_ForInvalidSession()
{
// Arrange
int testSessionId = 123;
var mockRepo = new Mock<IBrainstormSessionRepository>();
mockRepo.Setup(repo => repo.GetByIdAsync(testSessionId))
.ReturnsAsync((BrainstormSession)null);
var controller = new IdeasController(mockRepo.Object);
// Act
var result = await controller.Create(new NewIdeaModel());
// Assert
Assert.IsType<NotFoundObjectResult>(result);
}
Le troisième test Create, Create_ReturnsNewlyCreatedIdeaForSession, vérifie que la méthode UpdateAsync du dépôt est appelée. L’élément fictif est appelé avec Verifiable, puis la méthode Verify du dépôt fictif est appelée pour confirmer que la méthode vérifiable est exécutée. La garantie que la méthode UpdateAsync a enregistré les données ne relève pas de la responsabilité du test unitaire. Cette vérification peut être effectuée au moyen d’un test d’intégration.
[Fact]
public async Task Create_ReturnsNewlyCreatedIdeaForSession()
{
// Arrange
int testSessionId = 123;
string testName = "test name";
string testDescription = "test description";
var testSession = GetTestSession();
var mockRepo = new Mock<IBrainstormSessionRepository>();
mockRepo.Setup(repo => repo.GetByIdAsync(testSessionId))
.ReturnsAsync(testSession);
var controller = new IdeasController(mockRepo.Object);
var newIdea = new NewIdeaModel()
{
Description = testDescription,
Name = testName,
SessionId = testSessionId
};
mockRepo.Setup(repo => repo.UpdateAsync(testSession))
.Returns(Task.CompletedTask)
.Verifiable();
// Act
var result = await controller.Create(newIdea);
// Assert
var okResult = Assert.IsType<OkObjectResult>(result);
var returnSession = Assert.IsType<BrainstormSession>(okResult.Value);
mockRepo.Verify();
Assert.Equal(2, returnSession.Ideas.Count());
Assert.Equal(testName, returnSession.Ideas.LastOrDefault().Name);
Assert.Equal(testDescription, returnSession.Ideas.LastOrDefault().Description);
}
Tester ActionResult<T>
Dans ASP.NET Core 2.1 ou version ultérieure, ActionResultT<T> (ActionResult<TValue>) vous permet de retourner un type dérivant de ActionResult, ou de retourner un type spécifique.
L’exemple d’application comprend une méthode qui retourne un List<IdeaDTO> pour un id de session donné. Si l’id de session n’existe pas, le contrôleur retourne NotFound :
[HttpGet("forsessionactionresult/{sessionId}")]
[ProducesResponseType(200)]
[ProducesResponseType(404)]
public async Task<ActionResult<List<IdeaDTO>>> ForSessionActionResult(int sessionId)
{
var session = await _sessionRepository.GetByIdAsync(sessionId);
if (session == null)
{
return NotFound(sessionId);
}
var result = session.Ideas.Select(idea => new IdeaDTO()
{
Id = idea.Id,
Name = idea.Name,
Description = idea.Description,
DateCreated = idea.DateCreated
}).ToList();
return result;
}
Deux tests du contrôleur ForSessionActionResult sont inclus dans ApiIdeasControllerTests.
Le premier test confirme que le contrôleur retourne un ActionResult, et non une liste inexistante d’idées pour un id de session inexistant :
- Le type
ActionResultestActionResult<List<IdeaDTO>>. - Le Result est un NotFoundObjectResult.
[Fact]
public async Task ForSessionActionResult_ReturnsNotFoundObjectResultForNonexistentSession()
{
// Arrange
var mockRepo = new Mock<IBrainstormSessionRepository>();
var controller = new IdeasController(mockRepo.Object);
var nonExistentSessionId = 999;
// Act
var result = await controller.ForSessionActionResult(nonExistentSessionId);
// Assert
var actionResult = Assert.IsType<ActionResult<List<IdeaDTO>>>(result);
Assert.IsType<NotFoundObjectResult>(actionResult.Result);
}
Pour un id de session valide, le deuxième test confirme que la méthode retourne :
- Un
ActionResultavec un typeList<IdeaDTO>. -
ActionResult<T>.Value est un type
List<IdeaDTO>. - Le premier élément dans la liste est une idée valide correspondant à l’idée stockée dans la session fictive (obtenu en appelant
GetTestSession).
[Fact]
public async Task ForSessionActionResult_ReturnsIdeasForSession()
{
// Arrange
int testSessionId = 123;
var mockRepo = new Mock<IBrainstormSessionRepository>();
mockRepo.Setup(repo => repo.GetByIdAsync(testSessionId))
.ReturnsAsync(GetTestSession());
var controller = new IdeasController(mockRepo.Object);
// Act
var result = await controller.ForSessionActionResult(testSessionId);
// Assert
var actionResult = Assert.IsType<ActionResult<List<IdeaDTO>>>(result);
var returnValue = Assert.IsType<List<IdeaDTO>>(actionResult.Value);
var idea = returnValue.FirstOrDefault();
Assert.Equal("One", idea.Name);
}
L’exemple d’application comporte également une méthode pour créer un nouveau Idea pour une session donnée. Le contrôleur retourne :
- BadRequest pour un modèle non valide.
- NotFound si la session n’existe pas.
- CreatedAtAction lorsque la session est mise à jour avec la nouvelle idée.
[HttpPost("createactionresult")]
[ProducesResponseType(201)]
[ProducesResponseType(400)]
[ProducesResponseType(404)]
public async Task<ActionResult<BrainstormSession>> CreateActionResult([FromBody]NewIdeaModel model)
{
if (!ModelState.IsValid)
{
return BadRequest(ModelState);
}
var session = await _sessionRepository.GetByIdAsync(model.SessionId);
if (session == null)
{
return NotFound(model.SessionId);
}
var idea = new Idea()
{
DateCreated = DateTimeOffset.Now,
Description = model.Description,
Name = model.Name
};
session.AddIdea(idea);
await _sessionRepository.UpdateAsync(session);
return CreatedAtAction(nameof(CreateActionResult), new { id = session.Id }, session);
}
Trois tests de CreateActionResult sont inclus dans ApiIdeasControllerTests.
Le premier test confirme qu’un BadRequest est retourné pour un modèle non valide.
[Fact]
public async Task CreateActionResult_ReturnsBadRequest_GivenInvalidModel()
{
// Arrange & Act
var mockRepo = new Mock<IBrainstormSessionRepository>();
var controller = new IdeasController(mockRepo.Object);
controller.ModelState.AddModelError("error", "some error");
// Act
var result = await controller.CreateActionResult(model: null);
// Assert
var actionResult = Assert.IsType<ActionResult<BrainstormSession>>(result);
Assert.IsType<BadRequestObjectResult>(actionResult.Result);
}
Le deuxième test vérifie qu’un NotFound est retourné si la session n’existe pas.
[Fact]
public async Task CreateActionResult_ReturnsNotFoundObjectResultForNonexistentSession()
{
// Arrange
var nonExistentSessionId = 999;
string testName = "test name";
string testDescription = "test description";
var mockRepo = new Mock<IBrainstormSessionRepository>();
var controller = new IdeasController(mockRepo.Object);
var newIdea = new NewIdeaModel()
{
Description = testDescription,
Name = testName,
SessionId = nonExistentSessionId
};
// Act
var result = await controller.CreateActionResult(newIdea);
// Assert
var actionResult = Assert.IsType<ActionResult<BrainstormSession>>(result);
Assert.IsType<NotFoundObjectResult>(actionResult.Result);
}
Pour un id de session valide, le dernier test confirme que :
- La méthode retourne un
ActionResultavec un typeBrainstormSession. -
ActionResult<T>.Result est un CreatedAtActionResult.
CreatedAtActionResultest analogue à une réponse Créée 201 avec un en-têteLocation. -
ActionResult<T>.Value est un type
BrainstormSession. - L’appel fictif pour mettre à jour la session,
UpdateAsync(testSession), a été appelé. L’appel de la méthodeVerifiableest contrôlé en exécutantmockRepo.Verify()dans les assertions. - Deux objets
Ideasont retournés pour la session. - Le dernier élément (
Ideaajouté par l’appel fictif àUpdateAsync) correspond ànewIdeaajouté à la session dans le test.
[Fact]
public async Task CreateActionResult_ReturnsNewlyCreatedIdeaForSession()
{
// Arrange
int testSessionId = 123;
string testName = "test name";
string testDescription = "test description";
var testSession = GetTestSession();
var mockRepo = new Mock<IBrainstormSessionRepository>();
mockRepo.Setup(repo => repo.GetByIdAsync(testSessionId))
.ReturnsAsync(testSession);
var controller = new IdeasController(mockRepo.Object);
var newIdea = new NewIdeaModel()
{
Description = testDescription,
Name = testName,
SessionId = testSessionId
};
mockRepo.Setup(repo => repo.UpdateAsync(testSession))
.Returns(Task.CompletedTask)
.Verifiable();
// Act
var result = await controller.CreateActionResult(newIdea);
// Assert
var actionResult = Assert.IsType<ActionResult<BrainstormSession>>(result);
var createdAtActionResult = Assert.IsType<CreatedAtActionResult>(actionResult.Result);
var returnValue = Assert.IsType<BrainstormSession>(createdAtActionResult.Value);
mockRepo.Verify();
Assert.Equal(2, returnValue.Ideas.Count());
Assert.Equal(testName, returnValue.Ideas.LastOrDefault().Name);
Assert.Equal(testDescription, returnValue.Ideas.LastOrDefault().Description);
}
Ressources supplémentaires
- Tests d’intégration dans ASP.NET Core
- Créer et exécuter des tests unitaires avec Visual Studio
- MyTested.AspNetCore.Mvc – Bibliothèque de tests Fluent pour ASP.NET Core MVC : bibliothèque de tests unitaires fortement typée, fournissant une interface Fluent pour tester les applications MVC et d’API web. (Non géré ou pris en charge par Microsoft.)
- JustMockLite : framework de simulation pour les développeurs .NET. (Non géré ou pris en charge par Microsoft.)