Nota:
El acceso a esta página requiere autorización. Puede intentar iniciar sesión o cambiar directorios.
El acceso a esta página requiere autorización. Puede intentar cambiar los directorios.
Por Steve Smith
Las pruebas unitarias implican probar una parte de una aplicación de forma aislada con respecto a su infraestructura y dependencias. Cuando se realizan pruebas unitarias de la lógica de controlador, solo se comprueba el contenido de una única acción, no el comportamiento de sus dependencias o del marco en sí.
Pruebas unitarias de controladores
Configure las pruebas unitarias de las acciones del controlador para centrarse en el comportamiento del controlador. Una prueba unitaria del controlador evita escenarios como filtros, enrutamiento y enlace de modelos. Las pruebas que abarcan las interacciones entre los componentes que colectivamente responden a una solicitud se controlan mediante pruebas de integración. Para más información sobre pruebas de integración, vea Pruebas de integración en ASP.NET Core.
Si va a escribir filtros personalizados y rutas, realice pruebas unitarias en ellos de forma aislada, no como parte de las pruebas de una acción de controlador concreta.
Para demostrar las pruebas unitarias del controlador, revise el siguiente controlador en la aplicación de ejemplo.
Vea o descargue el código de ejemplo (cómo descargarlo)
El controlador Home muestra una lista de sesiones de lluvia de ideas y permite crear nuevas sesiones de lluvia de ideas con una solicitud 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));
}
}
El controlador anterior:
- Sigue el Principio de dependencias explícitas.
- Espera la inyección de dependencias (DI) para proporcionar una instancia de
IBrainstormSessionRepository. - Se puede probar con un servicio
IBrainstormSessionRepositoryficticio con el uso de un framework de objetos simulados, como Moq. Un objeto ficticio es un objeto fabricado con un conjunto predeterminado de comportamientos de propiedades y métodos utilizados para las pruebas. Para más información, vea Introducción a las pruebas de integración.
El método HTTP GET Index no tiene bucles ni bifurcaciones y solamente llama a un método. La prueba unitaria para esta acción:
- Simula el servicio
IBrainstormSessionRepositorymediante el métodoGetTestSessions.GetTestSessionscrea dos sesiones de lluvia de ideas ficticias con fechas y nombres de sesión. - Ejecuta el método
Index. - Realiza aserciones sobre el resultado devuelto por el método:
- Se devuelve un ViewResult.
-
ViewDataDictionary.Model es
StormSessionViewModel. - Hay dos sesiones de lluvia de ideas almacenadas en
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;
}
Las pruebas del método HTTP POST Index del controlador de Home verifican que:
- Cuando ModelState.IsValid es
false, el método de acción devuelve un 400 Bad RequestViewResult con los datos adecuados. - Cuando
ModelState.IsValidestrue:- Se llama al método
Adddel repositorio. - Se devuelve RedirectToActionResult con los argumentos correctos.
- Se llama al método
Se prueba un estado no válido del modelo agregando errores mediante AddModelError, como se muestra en la primera prueba que aparece a continuación:
[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();
}
Si ModelState no es válido, se devuelve el mismo elemento ViewResult que para una solicitud GET. La prueba no intenta pasar un modelo no válido. Pasar un modelo no válido no es una forma válida de proceder, ya que la vinculación de modelos no se ejecuta (aunque una prueba de integración sí utiliza la vinculación de modelos). En este caso, no se comprueba el enlace de modelos. Estas pruebas unitarias solo comprueban el código del método de acción.
La segunda prueba verifica que, cuando ModelState es válido:
- Se agrega un nuevo elemento
BrainstormSession(mediante el repositorio). - El método devuelve un elemento
RedirectToActionResultcon las propiedades esperadas.
Las llamadas simuladas que no se efectúan se suelen ignorar, aunque llamar a Verifiable al final de la llamada de configuración permite realizar una validación de los mocks en la prueba. Esto se realiza con una llamada a mockRepo.Verify, que producirá un error en la prueba si no se ha llamado al método esperado.
Note
La biblioteca Moq usada en este ejemplo permite mezclar fácilmente objetos ficticios comprobables o "estrictos" con objetos ficticios no comprobables (también denominados "flexibles" o stub). Obtenga más información sobre cómo personalizar el comportamiento de objetos ficticios con Moq.
SessionController en la aplicación de ejemplo muestra información relacionada con una sesión de lluvia de ideas determinada. El controlador incluye lógica para tratar valores id no válidos (hay dos escenarios return en el ejemplo siguiente para abarcar estos escenarios). La instrucción return final devuelve un nuevo elemento StormSessionViewModel en la vista (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);
}
}
Las pruebas unitarias incluyen una prueba para cada escenario return en la acción Index del controlador 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);
}
Al pasar al controlador de ideas, la aplicación expone la funcionalidad como una API web en la ruta api/ideas:
- El método
ForSessiondevuelve una lista de ideas (IdeaDTO) asociadas con una sesión de lluvia de ideas. - El método
Createagrega nuevas ideas a una sesión.
[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);
}
Evite devolver entidades de dominio de negocio directamente mediante llamadas API. Entidades de dominio:
- Suelen incluir más datos de los que necesita el cliente.
- Acople innecesariamente el modelo de dominio interno de la aplicación con la API expuesta públicamente.
La asignación entre las entidades de dominio y los tipos devueltos al cliente se puede realizar:
- Manualmente, con una operación
Selectde LINQ, como la aplicación de ejemplo usa. Para más información, vea LINQ (Language Integrated Query). - Automáticamente, con una biblioteca, como AutoMapper.
A continuación, la aplicación de ejemplo muestra las pruebas unitarias para los métodos de API Create y ForSession del controlador de ideas.
La aplicación de ejemplo contiene dos ForSession pruebas. La primera prueba determina si ForSession devuelve NotFoundObjectResult (HTTP no encontrado) para una sesión no válida:
[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 segunda prueba ForSession determina si ForSession devuelve una lista de ideas de sesión (<List<IdeaDTO>>) para una sesión válida. Las comprobaciones también examinan la primera idea para confirmar si su propiedad Name es correcta:
[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);
}
Para comprobar el comportamiento del método Create cuando ModelState no es válido, la aplicación de ejemplo agrega un error de modelo al controlador como parte de la prueba. No intente probar la validación del modelo o el enlace de modelos en las pruebas unitarias; céntrese en probar el comportamiento del método de acción al confrontarlo con un valor de ModelState no válido:
[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);
}
La segunda prueba de Create depende de que el repositorio devuelva null, por lo que el repositorio ficticio está configurado para devolver null. No es necesario crear una base de datos de prueba (en memoria o de cualquier otro modo) ni crear una consulta que devuelva este resultado. La prueba puede realizarse en una sola instrucción, como se muestra en el código de ejemplo:
[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);
}
La tercera prueba de Create, Create_ReturnsNewlyCreatedIdeaForSession, verifica que se llama al método UpdateAsync del repositorio. Se llama al objeto ficticio con Verifiable y, después, se llama al método Verify del repositorio ficticio para confirmar que se ejecutó el método Verifiable. La prueba unitaria no es responsable de garantizar que el método UpdateAsync guarda los datos; esto se puede realizar con una prueba de integración.
[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);
}
Probar ActionResult<T>
ActionResult<T> (ActionResult<TValue>) puede devolver un tipo que se deriva de ActionResult o bien un tipo específico.
La aplicación de ejemplo incluye un método que devuelve un resultado List<IdeaDTO> para una sesión determinada id. Si la sesión id no existe, el controlador devuelve 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;
}
Dos pruebas del controlador ForSessionActionResult se incluyen en ApiIdeasControllerTests.
La primera prueba confirma que el controlador devuelve ActionResult, pero no una lista inexistente de ideas para una sesión inexistente id:
- El tipo
ActionResultesActionResult<List<IdeaDTO>>. - El Result es 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);
}
Para una sesión válida id, la segunda prueba confirma que el método devuelve:
- Un
ActionResultcon un tipoList<IdeaDTO>. -
ActionResult<T>.Value es de tipo
List<IdeaDTO>. - El primer elemento de la lista es una idea válida que coincide con la idea almacenada en la sesión ficticia (obtenido mediante una llamada a
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);
}
La aplicación de ejemplo también incluye un método para crear un nuevo elemento Idea para una sesión determinada. El controlador devuelve:
- BadRequest para un modelo no válido.
- NotFound si no existe la sesión.
- CreatedAtAction cuando se actualiza la sesión con la nueva idea.
[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);
}
Tres pruebas de CreateActionResult se incluyen en ApiIdeasControllerTests.
El primer texto confirma que se devuelve un BadRequest para un modelo no válido.
[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);
}
La segunda prueba comprueba que se devuelve un NotFound si la sesión no existe.
[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);
}
Para una sesión válida id, la prueba final confirma que:
- El método devuelve un
ActionResultde tipoBrainstormSession. -
ActionResult<T>.Result es CreatedAtActionResult.
CreatedAtActionResultes similar a una respuesta 201 Creado con un encabezadoLocation. -
ActionResult<T>.Value es de tipo
BrainstormSession. - La llamada simulada para actualizar la sesión,
UpdateAsync(testSession), se invocó. La llamada al métodoVerifiablese comprueba mediante la ejecución demockRepo.Verify()en las aserciones. - Se devuelven dos objetos
Ideapara la sesión. - El último elemento (el
Ideaañadido por la llamada simulada aUpdateAsync) coincide con elnewIdeaañadido a la sesión en la prueba.
[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);
}
Los controles desempeñan un rol fundamental en cualquier aplicación de ASP.NET Core MVC. Por tanto, debe tener la seguridad de que los controladores se comportan según lo previsto. Las pruebas automatizadas pueden detectar errores antes de que la aplicación se implemente en un entorno de producción.
Vea o descargue el código de ejemplo (cómo descargarlo)
Pruebas unitarias de la lógica del controlador
Las pruebas unitarias implican probar una parte de una aplicación de forma aislada de su infraestructura y dependencias. Cuando se realizan pruebas unitarias de la lógica de controlador, solo se comprueba el contenido de una única acción, no el comportamiento de sus dependencias o del framework en sí.
Configure pruebas unitarias de las acciones del controlador para centrarse en el comportamiento del controlador. Una prueba unitaria del controlador evita escenarios como filtros, enrutamiento y enlace de modelos. Las pruebas que cubren las interacciones entre componentes que responden de forma conjunta a una solicitud se realizan mediante pruebas de integración. Para más información sobre pruebas de integración, vea Pruebas de integración en ASP.NET Core.
Si va a escribir filtros personalizados y rutas, realice pruebas unitarias en ellos de forma aislada, no como parte de las pruebas de una acción de controlador concreta.
Para demostrar las pruebas unitarias del controlador, revise el siguiente controlador en la aplicación de ejemplo. El controlador Home muestra una lista de sesiones de lluvia de ideas y permite crear nuevas sesiones de lluvia de ideas con una solicitud 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));
}
}
El controlador anterior:
- Sigue el Principio de dependencias explícitas.
- Espera la inserción de dependencias (DI) para ofrecer una instancia de
IBrainstormSessionRepository. - Se puede probar con un servicio
IBrainstormSessionRepositoryficticio con el uso de un marco de objeto ficticio, como Moq. Un objeto simulado es un objeto creado con un conjunto predefinido de propiedades y comportamientos de métodos que se utiliza para realizar pruebas. Para más información, vea Introducción a las pruebas de integración.
El método HTTP GET Index no tiene bucles ni bifurcaciones y solamente llama a un método. La prueba unitaria para esta acción:
- Realice el simulacro del servicio
IBrainstormSessionRepositorymediante el métodoGetTestSessions.GetTestSessionscrea dos sesiones de lluvia de ideas ficticias con fechas y nombres de sesión. - Ejecuta el método
Index. - Realiza aserciones sobre el resultado devuelto por el método:
- Se devuelve un/una ViewResult.
-
ViewDataDictionary.Model es
StormSessionViewModel. - Hay dos sesiones de lluvia de ideas almacenadas en
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;
}
Las pruebas del método HTTP POST Index del controlador de Home verifican que:
- Cuando ModelState.IsValid es
false, el método de acción devuelve un 400 Solicitud incorrectaViewResult con los datos adecuados. - Cuando
ModelState.IsValidestrue:- Se llama al método
Adddel repositorio. - Se devuelve RedirectToActionResult con los argumentos correctos.
- Se llama al método
Un estado de modelo no válido se comprueba mediante la adición de errores con AddModelError, como se muestra en la primera prueba de abajo:
[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();
}
Si ModelState no es válido, se devuelve el mismo ViewResult que para una solicitud GET. La prueba no intenta pasar un modelo no válido. Pasar un modelo no válido no es un enfoque válido, ya que el enlace de modelos no está en ejecución (aunque una prueba de integración usa el enlace de modelos). En este caso, no se comprueba el enlace de modelos. Estas pruebas unitarias solo prueban el código en el método de acción.
La segunda prueba verifica que, cuando ModelState es válido:
- Se agrega un nuevo
BrainstormSession(mediante el repositorio). - El método devuelve un elemento
RedirectToActionResultcon las propiedades esperadas.
Las llamadas simuladas que no se efectúan se suelen ignorar, aunque llamar a Verifiable al final de la llamada de configuración permite realizar una validación de los mocks en la prueba. Esto se realiza con una llamada a mockRepo.Verify, que producirá un error en la prueba si no se ha llamado al método esperado.
Note
La biblioteca Moq usada en este ejemplo permite mezclar fácilmente mocks comprobables o "strict" con mocks no comprobables (también denominados "loose" mocks o stubs). Obtenga más información sobre cómo personalizar el comportamiento de objetos ficticios con Moq.
SessionController en la aplicación de ejemplo muestra información relacionada con una sesión de lluvia de ideas determinada. El controlador incluye lógica para tratar valores id no válidos (hay dos escenarios return en el ejemplo siguiente para abarcar estos escenarios). La instrucción return final devuelve un nuevo elemento StormSessionViewModel en la vista (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);
}
}
Las pruebas unitarias incluyen una prueba para cada escenario return en la acción Index del controlador 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);
}
Al pasar al controlador de ideas, la aplicación expone la funcionalidad como una API web en la ruta api/ideas:
- Una lista de ideas (
IdeaDTO) asociada a una sesión de lluvia de ideas la devuelve el métodoForSession. - El método
Createagrega nuevas ideas a una sesión.
[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);
}
Evite devolver entidades de dominio de negocio directamente mediante llamadas API. Entidades de dominio:
- Suelen incluir más datos de los que necesita el cliente.
- Acople innecesariamente el modelo de dominio interno de la aplicación con la API expuesta públicamente.
La asignación entre las entidades de dominio y los tipos devueltos al cliente se puede realizar:
- Manualmente, con una operación
Selectde LINQ, como la aplicación de ejemplo usa. Para más información, vea LINQ (Language Integrated Query). - Automáticamente, con una biblioteca, como AutoMapper.
A continuación, la aplicación de ejemplo muestra las pruebas unitarias para los métodos de la API Create y ForSession del controlador Ideas.
La aplicación de ejemplo contiene dos ForSession pruebas. La primera prueba determina si ForSession devuelve NotFoundObjectResult (HTTP no encontrado) para una sesión no válida:
[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 segunda prueba ForSession determina si ForSession devuelve una lista de ideas de sesión (<List<IdeaDTO>>) para una sesión válida. Las comprobaciones también examinan la primera idea para confirmar si su propiedad Name es correcta:
[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);
}
Para comprobar el comportamiento del método Create cuando ModelState no es válido, la aplicación de ejemplo agrega un error de modelo al controlador como parte de la prueba. No intente probar la validación del modelo o el enlace de modelos en las pruebas unitarias; céntrese en probar el comportamiento del método de acción al confrontarlo con un valor de ModelState no válido:
[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);
}
La segunda prueba de Create depende de que el repositorio devuelva null, por lo que el repositorio ficticio está configurado para devolver null. No es necesario crear una base de datos de prueba (en memoria o de cualquier otro modo) ni crear una consulta que devuelva este resultado. La prueba puede realizarse en una sola instrucción, como se muestra en el código de ejemplo:
[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);
}
La tercera prueba de Create, Create_ReturnsNewlyCreatedIdeaForSession, verifica que se llama al método UpdateAsync del repositorio. Se llama al objeto ficticio con Verifiable y, después, se llama al método Verify del repositorio ficticio para confirmar que se ejecutó el método Verifiable. La prueba unitaria no es responsable de garantizar que el método UpdateAsync guarda los datos; esto se puede realizar con una prueba de integración.
[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);
}
Probar ActionResult<T>
En ASP.NET Core 2.1 o versiones posteriores, ActionResult<T> (ActionResult<TValue>) permite devolver tanto un tipo derivado de ActionResult como un tipo específico.
La aplicación de ejemplo incluye un método que devuelve un resultado List<IdeaDTO> para una sesión determinada id. Si la sesión id no existe, el controlador devuelve 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;
}
Dos pruebas del controlador ForSessionActionResult se incluyen en ApiIdeasControllerTests.
La primera prueba confirma que el controlador devuelve ActionResult, pero no una lista inexistente de ideas para una sesión inexistente id:
- El tipo
ActionResultesActionResult<List<IdeaDTO>>. - El Result es 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);
}
Para una sesión válida id, la segunda prueba confirma que el método devuelve:
- Un
ActionResultcon un tipoList<IdeaDTO>. -
ActionResult<T>.Value es de tipo
List<IdeaDTO>. - El primer elemento de la lista es una idea válida que coincide con la idea almacenada en la sesión ficticia (obtenido mediante una llamada a
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);
}
La aplicación de ejemplo también incluye un método para crear un nuevo elemento Idea para una sesión determinada. El controlador devuelve:
- BadRequest para un modelo no válido.
- NotFound si no existe la sesión.
- CreatedAtAction cuando se actualiza la sesión con la nueva idea.
[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);
}
Tres pruebas de CreateActionResult se incluyen en ApiIdeasControllerTests.
El primer texto confirma que se devuelve un BadRequest para un modelo no válido.
[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);
}
La segunda prueba comprueba que se devuelve un NotFound si la sesión no existe.
[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);
}
Para una sesión válida id, la prueba final confirma que:
- El método devuelve un
ActionResultde tipoBrainstormSession. -
ActionResult<T>.Result es CreatedAtActionResult.
CreatedAtActionResultes similar a una respuesta 201 Creado con un encabezadoLocation. -
ActionResult<T>.Value es de tipo
BrainstormSession. - La llamada simulada para actualizar la sesión,
UpdateAsync(testSession), se invocó. La llamada al métodoVerifiablese comprueba mediante la ejecución demockRepo.Verify()en las aserciones. - Se devuelven dos objetos
Ideapara la sesión. - El último elemento (el
Ideaañadido por la llamada simulada aUpdateAsync) coincide con elnewIdeaañadido a la sesión en la prueba.
[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);
}
Contenido relacionado
- Pruebas de integración en ASP.NET Core
- Crear y ejecutar pruebas unitarias con Visual Studio
- MyTested.AspNetCore.Mvc - biblioteca fluida de pruebas para ASP.NET Core MVC: Biblioteca de pruebas fuertemente tipada, que ofrece una interfaz fluida para la prueba de aplicaciones MVC y de API web. (Microsoft no realiza su mantenimiento ni su soporte técnico. )
- JustMockLite: framework de mocking para desarrolladores de .NET. (Microsoft no realiza su mantenimiento ni su soporte técnico. )