Partilhar via


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 de sua infraestrutura e dependências. Quando se realiza um teste unitário da lógica do controlador, apenas o conteúdo de uma única ação é testado, não o comportamento das suas dependências ou do próprio framework.

Controladores de teste unitário

Crie testes de unidade para ações do controlador para se concentrar no comportamento do controlador. Um teste de unidade de controlador evita cenários como filtros, roteamento e vinculação de modelo. Os testes que abrangem as interações entre componentes que respondem coletivamente a uma solicitação são manipulados por testes de integração. Para obter mais informações sobre testes de integração, consulte Testes de integração no ASP.NET Core.

Se você estiver escrevendo filtros e rotas personalizados, teste-os isoladamente, não como parte de testes em uma ação específica do controlador.

Para demonstrar os testes de unidade do controlador, revise o controlador a seguir no aplicativo de exemplo.

Visualizar ou descarregar amostra de código (como descarregar)

O Home controlador exibe uma lista de sessões de brainstorming e permite a criação de novas sessões de brainstorming 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:

O HTTP GET Index método não tem looping ou ramificação e chama apenas um método. O teste de unidade para esta ação:

  • Simula o IBrainstormSessionRepository serviço usando o GetTestSessions método. GetTestSessions Cria duas sessões de geração de ideias simuladas com datas e nomes de sessão.
  • Executa o Index método.
  • Faz asserções sobre o resultado retornado pelo método:
[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;
}

O método do Home controlador HTTP POST Index testa e verifica se:

  • Quando ModelState.IsValid é false, o método action retorna um 400 Bad RequestViewResult com os dados apropriados.
  • Quando ModelState.IsValid é true:

Um estado de modelo inválido é testado adicionando erros usando AddModelError como 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 é válido, o mesmo ViewResult é retornado como para uma solicitação GET. O teste não tenta passar em um modelo inválido. Passar um modelo inválido não é uma abordagem válida, uma vez que a vinculação de modelo não está em execução (embora um teste de integração use a vinculação de modelo). Nesse caso, a vinculação de modelo não é testada. Esses testes de unidade estão testando apenas o código no método de ação.

O segundo teste verifica que, quando o ModelState é válido:

  • Um novo BrainstormSession é adicionado (através do repositório).
  • O método retorna a RedirectToActionResult com as propriedades esperadas.

As chamadas simuladas que não são chamadas são normalmente ignoradas, mas a chamada Verifiable no final da chamada de configuração permite a validação simulada no teste. Isso é realizado com a chamada para mockRepo.Verify, que falha no teste se o método esperado não foi chamado.

Note

A biblioteca Moq usada neste exemplo torna possível misturar simulações verificáveis, ou "estritas", com simulações não verificáveis (também chamadas de simulações ou esboços "soltos"). Saiba mais sobre como personalizar o comportamento simulado com o Moq.

SessionController no aplicativo de exemplo exibe informações relacionadas a uma sessão de brainstorming específica. O controlador inclui lógica para lidar com valores inválidos id (há dois return cenários no exemplo a seguir para cobrir esses cenários). A instrução final return retorna um novo StormSessionViewModel para a visã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 return cenário na ação do controlador de Sessão Index:

[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);
}

Ao passar para o controlador Ideas, o aplicativo expõe funcionalidade como uma API da Web na rota api/ideas.

  • Uma lista de ideias (IdeaDTO) associadas a uma sessão de brainstorming é retornada pelo método ForSession.
  • O Create método 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 de API. Entidades de domínio:

  • Muitas vezes incluem mais dados do que o cliente exige.
  • Associe desnecessariamente o modelo de domínio interno do aplicativo à API exposta publicamente.

O mapeamento entre entidades de domínio e os tipos retornados ao cliente pode ser executado:

Em seguida, o aplicativo de exemplo demonstra testes de unidade para os métodos API Create e ForSession do controlador Ideas.

O aplicativo de exemplo contém dois ForSession testes. 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 ForSession teste 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 que sua Name propriedade 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 Create comportamento do método quando o ModelState é inválido, o aplicativo de exemplo adiciona um erro de modelo ao controlador como parte do teste. Não tente efetuar testes de validação ou associação do modelo em testes de unidade; apenas teste o comportamento do método de ação quando se depara com 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 retorno do null repositório, de modo que o repositório simulado está configurado para retornar null. Não há necessidade de criar um banco de dados de teste (na memória ou de outra forma) e construir uma consulta que retorna esse resultado. O teste pode ser realizado em uma única instrução, como o código de exemplo ilustra:

[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 Create teste, Create_ReturnsNewlyCreatedIdeaForSession, verifica se o método do UpdateAsync repositório é chamado. O mock é chamado com Verifiable, e o método do repositório simulado Verify é chamado para confirmar que o método verificável é executado. Não é responsabilidade do teste de unidade garantir que o UpdateAsync método salvou os dados — isso pode ser verificado 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);
}

Teste 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 determinada sessão id. 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 ForSessionActionResult controlador estão incluídos no ApiIdeasControllerTests.

O primeiro teste confirma que o controlador retorna um ActionResult, mas não uma lista inexistente de ideias para uma sessão id inexistente.

[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 idválida, o segundo teste confirma que o método retorna:

  • Um ActionResult do tipo List<IdeaDTO>.
  • O ActionResult<T>.Value é do List<IdeaDTO> tipo.
  • O primeiro item da lista é uma ideia válida correspondente à ideia armazenada na sessão simulada (obtida por chamada 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:

[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 de CreateActionResult estão incluídos no ApiIdeasControllerTests.

O primeiro texto confirma que um BadRequest é devolvido 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 a NotFound se retorna caso a sessão não exista.

[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 idválida, o teste final confirma que:

  • O método retorna um ActionResult com um BrainstormSession tipo.
  • O ActionResult<T>.Result é um CreatedAtActionResult. CreatedAtActionResult é análogo a uma resposta 201 Created com um Location cabeçalho.
  • O ActionResult<T>.Value é do BrainstormSession tipo.
  • A chamada simulada para atualizar a sessão, UpdateAsync(testSession), foi invocada. A chamada de método Verifiable é verificada executando mockRepo.Verify() nas afirmações.
  • Dois Idea objetos são retornados para a sessão.
  • O último item (o Idea adicionado pela chamada simulada para UpdateAsync) corresponde ao newIdea 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);
}

Os controladores desempenham um papel central em qualquer aplicativo MVC ASP.NET Core. Como tal, deve ter confiança de que os controladores se comportam como pretendido. Os testes automatizados podem detetar erros antes que o aplicativo seja implantado em um ambiente de produção.

Visualizar ou descarregar amostra de código (como descarregar)

Testes unitários da lógica do controlador

Os testes de unidade envolvem o teste de uma parte de um aplicativo isoladamente de sua infraestrutura e dependências. Quando se realiza um teste unitário da lógica do controlador, apenas o conteúdo de uma única ação é testado, não o comportamento das suas dependências ou do próprio framework.

Crie testes de unidade para ações do controlador para se concentrar no comportamento do controlador. Um teste de unidade de controlador evita cenários como filtros, roteamento e vinculação de modelo. Os testes que abrangem as interações entre componentes que respondem coletivamente a uma solicitação são manipulados por testes de integração. Para obter mais informações sobre testes de integração, consulte Testes de integração no ASP.NET Core.

Se você estiver escrevendo filtros e rotas personalizados, teste-os isoladamente, não como parte de testes em uma ação específica do controlador.

Para demonstrar os testes de unidade do controlador, revise o controlador a seguir no aplicativo de exemplo. O Home controlador exibe uma lista de sessões de brainstorming e permite a criação de novas sessões de brainstorming 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:

O HTTP GET Index método não tem looping ou ramificação e chama apenas um método. O teste de unidade para esta ação:

  • Simula o IBrainstormSessionRepository serviço usando o GetTestSessions método. GetTestSessions Cria duas sessões de geração de ideias simuladas com datas e nomes de sessão.
  • Executa o Index método.
  • Faz asserções sobre o resultado retornado pelo método:
[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;
}

O método do Home controlador HTTP POST Index testa e verifica se:

  • Quando ModelState.IsValid é false, o método action retorna um 400 Bad RequestViewResult com os dados apropriados.
  • Quando ModelState.IsValid é true:

Um estado de modelo inválido é testado adicionando erros usando AddModelError como 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 é válido, o mesmo ViewResult é retornado como para uma solicitação GET. O teste não tenta passar em um modelo inválido. Passar um modelo inválido não é uma abordagem válida, uma vez que a vinculação de modelo não está em execução (embora um teste de integração use a vinculação de modelo). Nesse caso, a vinculação de modelo não é testada. Esses testes de unidade estão testando apenas o código no método de ação.

O segundo teste verifica que, quando o ModelState é válido:

  • Um novo BrainstormSession é adicionado (através do repositório).
  • O método retorna a RedirectToActionResult com as propriedades esperadas.

As chamadas simuladas que não são chamadas são normalmente ignoradas, mas a chamada Verifiable no final da chamada de configuração permite a validação simulada no teste. Isso é realizado com a chamada para mockRepo.Verify, que falha no teste se o método esperado não foi chamado.

Note

A biblioteca Moq usada neste exemplo torna possível misturar simulações verificáveis, ou "estritas", com simulações não verificáveis (também chamadas de simulações ou esboços "soltos"). Saiba mais sobre como personalizar o comportamento simulado com o Moq.

SessionController no aplicativo de exemplo exibe informações relacionadas a uma sessão de brainstorming específica. O controlador inclui lógica para lidar com valores inválidos id (há dois return cenários no exemplo a seguir para cobrir esses cenários). A instrução final return retorna um novo StormSessionViewModel para a visã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 return cenário na ação do controlador de Sessão Index:

[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);
}

Ao passar para o controlador Ideas, o aplicativo expõe funcionalidade como uma API da Web na rota api/ideas.

  • Uma lista de ideias (IdeaDTO) associadas a uma sessão de brainstorming é retornada pelo método ForSession.
  • O Create método 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 de API. Entidades de domínio:

  • Muitas vezes incluem mais dados do que o cliente exige.
  • Associe desnecessariamente o modelo de domínio interno do aplicativo à API exposta publicamente.

O mapeamento entre entidades de domínio e os tipos retornados ao cliente pode ser executado:

Em seguida, o aplicativo de exemplo demonstra testes de unidade para os métodos API Create e ForSession do controlador Ideas.

O aplicativo de exemplo contém dois ForSession testes. 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 ForSession teste 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 que sua Name propriedade 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 Create comportamento do método quando o ModelState é inválido, o aplicativo de exemplo adiciona um erro de modelo ao controlador como parte do teste. Não tente efetuar testes de validação ou associação do modelo em testes de unidade; apenas teste o comportamento do método de ação quando se depara com 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 retorno do null repositório, de modo que o repositório simulado está configurado para retornar null. Não há necessidade de criar um banco de dados de teste (na memória ou de outra forma) e construir uma consulta que retorna esse resultado. O teste pode ser realizado em uma única instrução, como o código de exemplo ilustra:

[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 Create teste, Create_ReturnsNewlyCreatedIdeaForSession, verifica se o método do UpdateAsync repositório é chamado. O mock é chamado com Verifiable, e o método do repositório simulado Verify é chamado para confirmar que o método verificável é executado. Não é responsabilidade do teste de unidade garantir que o UpdateAsync método salvou os dados — isso pode ser verificado 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);
}

Teste ActionResult<T>

No ASP.NET Core 2.1 ou posterior, ActionResult<T> (ActionResult<TValue>) permite devolver um tipo que deriva de ActionResult ou um tipo específico.

O aplicativo de exemplo inclui um método que retorna um List<IdeaDTO> para uma determinada sessão id. 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 ForSessionActionResult controlador estão incluídos no ApiIdeasControllerTests.

O primeiro teste confirma que o controlador retorna um ActionResult, mas não uma lista inexistente de ideias para uma sessão id inexistente.

[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 idválida, o segundo teste confirma que o método retorna:

  • Um ActionResult do tipo List<IdeaDTO>.
  • O ActionResult<T>.Value é do List<IdeaDTO> tipo.
  • O primeiro item da lista é uma ideia válida correspondente à ideia armazenada na sessão simulada (obtida por chamada 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:

[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 de CreateActionResult estão incluídos no ApiIdeasControllerTests.

O primeiro texto confirma que um BadRequest é devolvido 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 a NotFound se retorna caso a sessão não exista.

[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 idválida, o teste final confirma que:

  • O método retorna um ActionResult com um BrainstormSession tipo.
  • O ActionResult<T>.Result é um CreatedAtActionResult. CreatedAtActionResult é análogo a uma resposta 201 Created com um Location cabeçalho.
  • O ActionResult<T>.Value é do BrainstormSession tipo.
  • A chamada simulada para atualizar a sessão, UpdateAsync(testSession), foi invocada. A chamada de método Verifiable é verificada executando mockRepo.Verify() nas afirmações.
  • Dois Idea objetos são retornados para a sessão.
  • O último item (o Idea adicionado pela chamada simulada para UpdateAsync) corresponde ao newIdea 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