Lógica do controlador de teste de unidade no ASP.NET Core
Por Steve Smith
Os testes de unidade envolvem o teste de uma parte de um aplicativo isoladamente em relação à sua infraestrutura e às suas dependências. Quando a unidade está testando a lógica do controlador, somente o conteúdo de uma única ação é testada, não o comportamento de suas dependências ou da estrutura em si.
Controladores de teste de unidade
Configure testes de unidade de ações do controlador para se concentrarem no comportamento do controlador. Um teste de unidade do controlador evita cenários como filtros, roteamento ou model binding. Os testes que abrangem as interações entre os componentes que respondem coletivamente a uma solicitação são manipulados pelos testes de integração. Para obter mais informações sobre testes de integração, confira Testes de integração no ASP.NET Core.
Se você estiver escrevendo filtros e rotas personalizados, realize testes de unidade neles de forma isolada, não como parte dos testes em uma ação do controlador específica.
Para demonstrar testes de unidade do controlador, examine o controlador a seguir no aplicativo de exemplo.
Exibir ou baixar código de exemplo (como baixar)
O controlador Home exibe uma lista de sessões de debate e permite que novas sessões sejam criadas com uma solicitação 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));
}
}
O controlador anterior:
- Segue o Princípio de Dependências Explícitas.
- Espera DI (injeção de dependência) para fornecer uma instância de
IBrainstormSessionRepository
. - Pode ser testado com um serviço
IBrainstormSessionRepository
fictício usando uma estrutura de objeto fictício, como Moq. Um objeto fictício é um objeto fabricado com um conjunto predeterminado de comportamentos de propriedade e de método usado para teste. Para saber mais, consulte Introdução aos testes de integração.
O método HTTP GET Index
não tem nenhum loop ou branch e chama apenas um método. O teste de unidade para esta ação:
- Imita o serviço
IBrainstormSessionRepository
usando o métodoGetTestSessions
.GetTestSessions
cria duas sessões de debate fictícias com datas e nomes de sessão. - Executa o método
Index
. - Faz declarações sobre o resultado retornado pelo método:
- Um ViewResult é retornado.
- O ViewDataDictionary.Model é um
StormSessionViewModel
. - Há duas sessões de debate armazenadas no
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;
}
Os testes do método HTTP POST Index
do controlador Home verificam se:
- Quando ModelState.IsValid é
false
, o método de ação retorna um 400 Bad Request ViewResult com os dados apropriados. - Quando
ModelState.IsValid
étrue
:- O método
Add
no repositório é chamado. - Um RedirectToActionResult é retornado com os argumentos corretos.
- O método
O estado de modelo inválido é testado por meio da adição de erros usando AddModelError, conforme mostrado no primeiro teste abaixo:
[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();
}
Quando ModelState não for válido, o mesmo ViewResult
será retornado para uma solicitação GET. O teste não tenta passar um modelo inválido. Passar um modelo inválido não é uma abordagem válida, visto que o model binding não está em execução (embora um teste de integração use model binding). Nesse caso, o model binding não está sendo testado. Esses testes de unidade estão testando apenas o código no método de ação.
O segundo teste verifica se, quando o ModelState
é válido:
- Um novo
BrainstormSession
é adicionado (por meio do repositório). - O método retorna um
RedirectToActionResult
com as propriedades esperadas.
Chamadas fictícias que não são chamadas são normalmente ignoradas, mas a chamada a Verifiable
no final da chamada de instalação permite a validação fictícia no teste. Isso é realizado com a chamada a mockRepo.Verify
, que não será aprovada no teste se o método esperado não tiver sido chamado.
Observação
A biblioteca do Moq usada neste exemplo possibilita a combinação de simulações verificáveis ou "estritas" com simulações não verificáveis (também chamadas de simulações "flexíveis" ou stubs). Saiba mais sobre como personalizar o comportamento de Simulação com o Moq.
SessionController no exemplo de aplicativo exibe informações relacionadas a uma sessão de debate específica. O controlador inclui lógica para lidar com valores id
inválidos (há dois cenários return
no exemplo a seguir para abordar esses cenários). A instrução return
final retorna um novo StormSessionViewModel
para a exibição (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);
}
}
Os testes de unidade incluem um teste para cada cenário return
na ação Index
do 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);
}
Mudando para o controlador Ideas, o aplicativo expõe a funcionalidade como uma API Web na rota api/ideas
:
- Uma lista de ideias (
IdeaDTO
) associada com uma sessão de debate é retornada pelo métodoForSession
. - O método
Create
adiciona novas ideias a uma sessão.
[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 retornar entidades de domínio de negócios diretamente por meio de chamadas à API. Entidades de domínio:
- Geralmente incluem mais dados do que o cliente necessita.
- Acople desnecessariamente o modelo de domínio interno do aplicativo à API exposta publicamente.
É possível executar o mapeamento entre entidades de domínio e os tipos retornados ao cliente:
- Manualmente com um LINQ
Select
, como o aplicativo de exemplo usa. Para saber mais, consulte LINQ (Consulta Integrada à Linguagem). - Automaticamente com uma biblioteca, como AutoMapper.
Em seguida, o aplicativo de exemplo demonstra os testes de unidade para os métodos de API Create
e ForSession
do controlador Ideas.
O aplicativo de exemplo contém dois testes ForSession
. O primeiro teste determina se ForSession
retorna um NotFoundObjectResult (HTTP não encontrado) para uma sessão invá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);
}
O segundo teste ForSession
determina se ForSession
retorna uma lista de ideias de sessão (<List<IdeaDTO>>
) para uma sessão válida. As verificações também examinam a primeira ideia para confirmar se sua propriedade Name
está correta:
[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 testar o comportamento do método Create
quando o ModelState
é inválido, o aplicativo de exemplo adiciona um erro de modelo ao controlador como parte do teste. Não tente testar a validação de modelos ou o model binding em testes de unidade. Teste apenas o comportamento do método de ação quando houver um ModelState
invá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);
}
O segundo teste de Create
depende do repositório retornar null
, portanto, o repositório fictício é configurado para retornar null
. Não é necessário criar um banco de dados de teste (na memória ou de outro tipo) e construir uma consulta que retornará esse resultado. O teste pode ser realizado em uma única instrução, como mostrado no código de exemplo:
[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);
}
O terceiro teste Create
, Create_ReturnsNewlyCreatedIdeaForSession
, verifica se o método UpdateAsync
do repositório é chamado. A simulação é chamada com Verifiable
e o método Verify
do repositório fictício é chamado para confirmar se o método verificável é executado. Não é responsabilidade do teste de unidade garantir que o método UpdateAsync
salve os dados. Isso pode ser realizado com um teste de integração.
[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);
}
Testar ActionResult<T>
ActionResult<T> (ActionResult<TValue>) pode retornar um tipo derivado de ActionResult
ou retornar um tipo específico.
O aplicativo de exemplo inclui um método que retorna um List<IdeaDTO>
para uma sessão id
determinada. Se a sessão id
não existir, o controlador retornará 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;
}
Dois testes do controlador ForSessionActionResult
estão incluídos no ApiIdeasControllerTests
.
O primeiro teste confirma se o controlador retorna um ActionResult
, mas não uma lista de ideias inexistente para uma sessão id
inexistente:
- O tipo
ActionResult<List<IdeaDTO>>
éActionResult
. - O Result é um 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 uma sessão id
válida, o segundo teste confirma se o método retorna:
- Um
ActionResult
com um tipoList<IdeaDTO>
. - O ActionResult<T>.Value é um tipo
List<IdeaDTO>
. - O primeiro item na lista é uma ideia válida que corresponde à ideia armazenada na sessão fictícia (obtida chamando
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);
}
O aplicativo de exemplo também inclui um método para criar um novo Idea
para uma determinada sessão. O controlador retorna:
- BadRequest para um modelo inválido.
- NotFound se a sessão não existir.
- CreatedAtAction quando a sessão for atualizada com a nova ideia.
[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);
}
Três testes CreateActionResult
estão incluídos no ApiIdeasControllerTests
.
O primeiro texto confirma que um BadRequest é retornado para um modelo invá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);
}
O segundo teste verifica se um NotFound será retornado se a sessão não existir.
[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 uma sessão id
válida, o teste final confirmará se:
- O método retorna um
ActionResult
com um tipoBrainstormSession
. - O ActionResult<T>.Result é um CreatedAtActionResult.
CreatedAtActionResult
é semelhante à resposta 201 Criado com um cabeçalhoLocation
. - O ActionResult<T>.Value é um tipo
BrainstormSession
. - A chamada fictícia para atualizar a sessão,
UpdateAsync(testSession)
, foi invocada. A chamada de métodoVerifiable
é verificada por meio da execução demockRepo.Verify()
nas declarações. - Dois objetos
Idea
são retornados para a sessão. - O último item (o
Idea
adicionado pela chamada fictícia aUpdateAsync
) corresponde aonewIdea
adicionado à sessão no teste.
[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);
}
Controladores desempenham um papel central em qualquer aplicativo ASP.NET Core MVC. Assim, você precisa estar confiante de que os controladores se comportarão conforme o esperado. Testes automatizados podem detectar erros antes do aplicativo ser implantado em um ambiente de produção.
Exibir ou baixar código de exemplo (como baixar)
Testes de unidade da lógica do controlador
Os testes de unidade envolvem o teste de uma parte de um aplicativo isoladamente em relação à sua infraestrutura e às suas dependências. Quando a unidade está testando a lógica do controlador, somente o conteúdo de uma única ação é testada, não o comportamento de suas dependências ou da estrutura em si.
Configure testes de unidade de ações do controlador para se concentrarem no comportamento do controlador. Um teste de unidade do controlador evita cenários como filtros, roteamento ou model binding. Os testes que abrangem as interações entre os componentes que respondem coletivamente a uma solicitação são manipulados pelos testes de integração. Para obter mais informações sobre testes de integração, confira Testes de integração no ASP.NET Core.
Se você estiver escrevendo filtros e rotas personalizados, realize testes de unidade neles de forma isolada, não como parte dos testes em uma ação do controlador específica.
Para demonstrar testes de unidade do controlador, examine o controlador a seguir no aplicativo de exemplo. O controlador Home exibe uma lista de sessões de debate e permite que novas sessões sejam criadas com uma solicitação 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));
}
}
O controlador anterior:
- Segue o Princípio de Dependências Explícitas.
- Espera DI (injeção de dependência) para fornecer uma instância de
IBrainstormSessionRepository
. - Pode ser testado com um serviço
IBrainstormSessionRepository
fictício usando uma estrutura de objeto fictício, como Moq. Um objeto fictício é um objeto fabricado com um conjunto predeterminado de comportamentos de propriedade e de método usado para teste. Para saber mais, consulte Introdução aos testes de integração.
O método HTTP GET Index
não tem nenhum loop ou branch e chama apenas um método. O teste de unidade para esta ação:
- Imita o serviço
IBrainstormSessionRepository
usando o métodoGetTestSessions
.GetTestSessions
cria duas sessões de debate fictícias com datas e nomes de sessão. - Executa o método
Index
. - Faz declarações sobre o resultado retornado pelo método:
- Um ViewResult é retornado.
- O ViewDataDictionary.Model é um
StormSessionViewModel
. - Há duas sessões de debate armazenadas no
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;
}
Os testes do método HTTP POST Index
do controlador Home verificam se:
- Quando ModelState.IsValid é
false
, o método de ação retorna um 400 Bad Request ViewResult com os dados apropriados. - Quando
ModelState.IsValid
étrue
:- O método
Add
no repositório é chamado. - Um RedirectToActionResult é retornado com os argumentos corretos.
- O método
O estado de modelo inválido é testado por meio da adição de erros usando AddModelError, conforme mostrado no primeiro teste abaixo:
[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();
}
Quando ModelState não for válido, o mesmo ViewResult
será retornado para uma solicitação GET. O teste não tenta passar um modelo inválido. Passar um modelo inválido não é uma abordagem válida, visto que o model binding não está em execução (embora um teste de integração use model binding). Nesse caso, o model binding não está sendo testado. Esses testes de unidade estão testando apenas o código no método de ação.
O segundo teste verifica se, quando o ModelState
é válido:
- Um novo
BrainstormSession
é adicionado (por meio do repositório). - O método retorna um
RedirectToActionResult
com as propriedades esperadas.
Chamadas fictícias que não são chamadas são normalmente ignoradas, mas a chamada a Verifiable
no final da chamada de instalação permite a validação fictícia no teste. Isso é realizado com a chamada a mockRepo.Verify
, que não será aprovada no teste se o método esperado não tiver sido chamado.
Observação
A biblioteca do Moq usada neste exemplo possibilita a combinação de simulações verificáveis ou "estritas" com simulações não verificáveis (também chamadas de simulações "flexíveis" ou stubs). Saiba mais sobre como personalizar o comportamento de Simulação com o Moq.
SessionController no exemplo de aplicativo exibe informações relacionadas a uma sessão de debate específica. O controlador inclui lógica para lidar com valores id
inválidos (há dois cenários return
no exemplo a seguir para abordar esses cenários). A instrução return
final retorna um novo StormSessionViewModel
para a exibição (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);
}
}
Os testes de unidade incluem um teste para cada cenário return
na ação Index
do 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);
}
Mudando para o controlador Ideas, o aplicativo expõe a funcionalidade como uma API Web na rota api/ideas
:
- Uma lista de ideias (
IdeaDTO
) associada com uma sessão de debate é retornada pelo métodoForSession
. - O método
Create
adiciona novas ideias a uma sessão.
[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 retornar entidades de domínio de negócios diretamente por meio de chamadas à API. Entidades de domínio:
- Geralmente incluem mais dados do que o cliente necessita.
- Acople desnecessariamente o modelo de domínio interno do aplicativo à API exposta publicamente.
É possível executar o mapeamento entre entidades de domínio e os tipos retornados ao cliente:
- Manualmente com um LINQ
Select
, como o aplicativo de exemplo usa. Para saber mais, consulte LINQ (Consulta Integrada à Linguagem). - Automaticamente com uma biblioteca, como AutoMapper.
Em seguida, o aplicativo de exemplo demonstra os testes de unidade para os métodos de API Create
e ForSession
do controlador Ideas.
O aplicativo de exemplo contém dois testes ForSession
. O primeiro teste determina se ForSession
retorna um NotFoundObjectResult (HTTP não encontrado) para uma sessão invá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);
}
O segundo teste ForSession
determina se ForSession
retorna uma lista de ideias de sessão (<List<IdeaDTO>>
) para uma sessão válida. As verificações também examinam a primeira ideia para confirmar se sua propriedade Name
está correta:
[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 testar o comportamento do método Create
quando o ModelState
é inválido, o aplicativo de exemplo adiciona um erro de modelo ao controlador como parte do teste. Não tente testar a validação de modelos ou o model binding em testes de unidade. Teste apenas o comportamento do método de ação quando houver um ModelState
invá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);
}
O segundo teste de Create
depende do repositório retornar null
, portanto, o repositório fictício é configurado para retornar null
. Não é necessário criar um banco de dados de teste (na memória ou de outro tipo) e construir uma consulta que retornará esse resultado. O teste pode ser realizado em uma única instrução, como mostrado no código de exemplo:
[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);
}
O terceiro teste Create
, Create_ReturnsNewlyCreatedIdeaForSession
, verifica se o método UpdateAsync
do repositório é chamado. A simulação é chamada com Verifiable
e o método Verify
do repositório fictício é chamado para confirmar se o método verificável é executado. Não é responsabilidade do teste de unidade garantir que o método UpdateAsync
salve os dados. Isso pode ser realizado com um teste de integração.
[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);
}
Testar ActionResult<T>
No ASP.NET Core 2.1 ou posterior, o ActionResult<T> (ActionResult<TValue>) permite que você retorne um tipo derivado de ActionResult
ou retorne um tipo específico.
O aplicativo de exemplo inclui um método que retorna um List<IdeaDTO>
para uma sessão id
determinada. Se a sessão id
não existir, o controlador retornará 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;
}
Dois testes do controlador ForSessionActionResult
estão incluídos no ApiIdeasControllerTests
.
O primeiro teste confirma se o controlador retorna um ActionResult
, mas não uma lista de ideias inexistente para uma sessão id
inexistente:
- O tipo
ActionResult<List<IdeaDTO>>
éActionResult
. - O Result é um 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 uma sessão id
válida, o segundo teste confirma se o método retorna:
- Um
ActionResult
com um tipoList<IdeaDTO>
. - O ActionResult<T>.Value é um tipo
List<IdeaDTO>
. - O primeiro item na lista é uma ideia válida que corresponde à ideia armazenada na sessão fictícia (obtida chamando
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);
}
O aplicativo de exemplo também inclui um método para criar um novo Idea
para uma determinada sessão. O controlador retorna:
- BadRequest para um modelo inválido.
- NotFound se a sessão não existir.
- CreatedAtAction quando a sessão for atualizada com a nova ideia.
[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);
}
Três testes CreateActionResult
estão incluídos no ApiIdeasControllerTests
.
O primeiro texto confirma que um BadRequest é retornado para um modelo invá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);
}
O segundo teste verifica se um NotFound será retornado se a sessão não existir.
[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 uma sessão id
válida, o teste final confirmará se:
- O método retorna um
ActionResult
com um tipoBrainstormSession
. - O ActionResult<T>.Result é um CreatedAtActionResult.
CreatedAtActionResult
é semelhante à resposta 201 Criado com um cabeçalhoLocation
. - O ActionResult<T>.Value é um tipo
BrainstormSession
. - A chamada fictícia para atualizar a sessão,
UpdateAsync(testSession)
, foi invocada. A chamada de métodoVerifiable
é verificada por meio da execução demockRepo.Verify()
nas declarações. - Dois objetos
Idea
são retornados para a sessão. - O último item (o
Idea
adicionado pela chamada fictícia aUpdateAsync
) corresponde aonewIdea
adicionado à sessão no teste.
[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);
}
Recursos adicionais
- Testes de integração no ASP.NET Core
- Crie e execute testes de unidade com o Visual Studio
- MyTested.AspNetCore.Mvc – Biblioteca de Testes Fluentes para ASP.NET Core MVC: biblioteca de testes de unidade fortemente tipada, fornecendo uma interface fluente para testar aplicativos de MVC e API Web. (Não mantido ou com suporte da Microsoft.)
- JustMockLite: uma estrutura de simulação para desenvolvedores do .NET. (Não mantido ou com suporte da Microsoft.)