Controladores de teste de unidade no ASP.NET Web API 2

Este tópico descreve algumas técnicas específicas para controladores de teste de unidade na API Web 2. Antes de ler este tópico, talvez você queira ler o tutorial Teste de Unidade ASP.NET Web API 2, que mostra como adicionar um projeto de teste de unidade à sua solução.

Versões de software usadas no tutorial

Observação

Usei Moq, mas a mesma ideia se aplica a qualquer estrutura de simulação. O Moq 4.5.30 (e posterior) dá suporte ao Visual Studio 2017, Roslyn e .NET 4.5 e versões posteriores.

Um padrão comum em testes de unidade é "arrange-act-assert":

  • Organizar: configure os pré-requisitos para que o teste seja executado.
  • Agir: execute o teste.
  • Assert: verifique se o teste foi bem-sucedido.

Na etapa de organização, você geralmente usará objetos mock ou stub. Isso minimiza o número de dependências, portanto, o teste se concentra em testar uma coisa.

Aqui estão algumas coisas que você deve fazer teste de unidade em seus controladores de API Web:

  • A ação retorna o tipo correto de resposta.
  • Parâmetros inválidos retornam a resposta de erro correta.
  • A ação chama o método correto no repositório ou na camada de serviço.
  • Se a resposta incluir um modelo de domínio, verifique o tipo de modelo.

Essas são algumas das coisas gerais a serem testadas, mas as especificações dependem da implementação do controlador. Em particular, faz uma grande diferença se as ações do controlador retornam HttpResponseMessage ou IHttpActionResult. Para obter mais informações sobre esses tipos de resultado, consulte Resultados da ação na API Web 2.

Testando ações que retornam HttpResponseMessage

Aqui está um exemplo de um controlador cujas ações retornam HttpResponseMessage.

public class ProductsController : ApiController
{
    IProductRepository _repository;

    public ProductsController(IProductRepository repository)
    {
        _repository = repository;
    }

    public HttpResponseMessage Get(int id)
    {
        Product product = _repository.GetById(id);
        if (product == null)
        {
            return Request.CreateResponse(HttpStatusCode.NotFound);
        }
        return Request.CreateResponse(product);
    }

    public HttpResponseMessage Post(Product product)
    {
        _repository.Add(product);

        var response = Request.CreateResponse(HttpStatusCode.Created, product);
        string uri = Url.Link("DefaultApi", new { id = product.Id });
        response.Headers.Location = new Uri(uri);

        return response;
    }
}

Observe que o controlador usa injeção de dependência para injetar um IProductRepository. Isso torna o controlador mais testável, pois você pode injetar um repositório fictício. O teste de unidade a seguir verifica se o Get método grava um Product no corpo da resposta. Suponha que repository seja uma simulação IProductRepository.

[TestMethod]
public void GetReturnsProduct()
{
    // Arrange
    var controller = new ProductsController(repository);
    controller.Request = new HttpRequestMessage();
    controller.Configuration = new HttpConfiguration();

    // Act
    var response = controller.Get(10);

    // Assert
    Product product;
    Assert.IsTrue(response.TryGetContentValue<Product>(out product));
    Assert.AreEqual(10, product.Id);
}

É importante definirSolicitação e Configuração no controlador. Caso contrário, o teste falhará com argumentNullException ou InvalidOperationException.

O Post método chama UrlHelper.Link para criar links na resposta. Isso requer um pouco mais de configuração no teste de unidade:

[TestMethod]
public void PostSetsLocationHeader()
{
    // Arrange
    ProductsController controller = new ProductsController(repository);

    controller.Request = new HttpRequestMessage { 
        RequestUri = new Uri("http://localhost/api/products") 
    };
    controller.Configuration = new HttpConfiguration();
    controller.Configuration.Routes.MapHttpRoute(
        name: "DefaultApi", 
        routeTemplate: "api/{controller}/{id}",
        defaults: new { id = RouteParameter.Optional });

    controller.RequestContext.RouteData = new HttpRouteData(
        route: new HttpRoute(),
        values: new HttpRouteValueDictionary { { "controller", "products" } });

    // Act
    Product product = new Product() { Id = 42, Name = "Product1" };
    var response = controller.Post(product);

    // Assert
    Assert.AreEqual("http://localhost/api/products/42", response.Headers.Location.AbsoluteUri);
}

A classe UrlHelper precisa da URL de solicitação e dos dados de rota, portanto, o teste precisa definir valores para eles. Outra opção é mock ou stub UrlHelper. Com essa abordagem, você substitui o valor padrão de ApiController.Url por uma versão fictícia ou stub que retorna um valor fixo.

Vamos reescrever o teste usando a estrutura Moq . Instale o Moq pacote NuGet no projeto de teste.

[TestMethod]
public void PostSetsLocationHeader_MockVersion()
{
    // This version uses a mock UrlHelper.

    // Arrange
    ProductsController controller = new ProductsController(repository);
    controller.Request = new HttpRequestMessage();
    controller.Configuration = new HttpConfiguration();

    string locationUrl = "http://location/";

    // Create the mock and set up the Link method, which is used to create the Location header.
    // The mock version returns a fixed string.
    var mockUrlHelper = new Mock<UrlHelper>();
    mockUrlHelper.Setup(x => x.Link(It.IsAny<string>(), It.IsAny<object>())).Returns(locationUrl);
    controller.Url = mockUrlHelper.Object;

    // Act
    Product product = new Product() { Id = 42 };
    var response = controller.Post(product);

    // Assert
    Assert.AreEqual(locationUrl, response.Headers.Location.AbsoluteUri);
}

Nesta versão, você não precisa configurar nenhum dado de rota, pois o UrlHelper fictício retorna uma cadeia de caracteres constante.

Testando ações que retornam IHttpActionResult

Na API Web 2, uma ação do controlador pode retornar IHttpActionResult, que é análogo a ActionResult no ASP.NET MVC. A interface IHttpActionResult define um padrão de comando para criar respostas HTTP. Em vez de criar a resposta diretamente, o controlador retorna um IHttpActionResult. Posteriormente, o pipeline invoca o IHttpActionResult para criar a resposta. Essa abordagem facilita a gravação de testes de unidade, pois você pode ignorar grande parte da configuração necessária para HttpResponseMessage.

Aqui está um controlador de exemplo cujas ações retornam IHttpActionResult.

public class Products2Controller : ApiController
{
    IProductRepository _repository;

    public Products2Controller(IProductRepository repository)
    {
        _repository = repository;
    }

    public IHttpActionResult Get(int id)
    {
        Product product = _repository.GetById(id);
        if (product == null)
        {
            return NotFound();
        }
        return Ok(product);
    }

    public IHttpActionResult Post(Product product)
    {
        _repository.Add(product);
        return CreatedAtRoute("DefaultApi", new { id = product.Id }, product);
    }

    public IHttpActionResult Delete(int id)
    {
        _repository.Delete(id);
        return Ok();
    }

    public IHttpActionResult Put(Product product)
    {
        // Do some work (not shown).
        return Content(HttpStatusCode.Accepted, product);
    }    
}

Este exemplo mostra alguns padrões comuns usando IHttpActionResult. Vamos ver como testá-los por unidade.

A ação retorna 200 (OK) com um corpo de resposta

O Get método chamará Ok(product) se o produto for encontrado. No teste de unidade, verifique se o tipo de retorno é OkNegotiatedContentResult e se o produto retornado tem a ID correta.

[TestMethod]
public void GetReturnsProductWithSameId()
{
    // Arrange
    var mockRepository = new Mock<IProductRepository>();
    mockRepository.Setup(x => x.GetById(42))
        .Returns(new Product { Id = 42 });

    var controller = new Products2Controller(mockRepository.Object);

    // Act
    IHttpActionResult actionResult = controller.Get(42);
    var contentResult = actionResult as OkNegotiatedContentResult<Product>;

    // Assert
    Assert.IsNotNull(contentResult);
    Assert.IsNotNull(contentResult.Content);
    Assert.AreEqual(42, contentResult.Content.Id);
}

Observe que o teste de unidade não executa o resultado da ação. Você pode assumir que o resultado da ação cria a resposta HTTP corretamente. (É por isso que a estrutura da API Web tem seus próprios testes de unidade!)

A ação retorna 404 (Não Encontrado)

O Get método chamará NotFound() se o produto não for encontrado. Para esse caso, o teste de unidade apenas verifica se o tipo de retorno é NotFoundResult.

[TestMethod]
public void GetReturnsNotFound()
{
    // Arrange
    var mockRepository = new Mock<IProductRepository>();
    var controller = new Products2Controller(mockRepository.Object);

    // Act
    IHttpActionResult actionResult = controller.Get(10);

    // Assert
    Assert.IsInstanceOfType(actionResult, typeof(NotFoundResult));
}

A ação retorna 200 (OK) sem corpo de resposta

O Delete método chama Ok() para retornar uma resposta HTTP 200 vazia. Como no exemplo anterior, o teste de unidade verifica o tipo de retorno, nesse caso , OkResult.

[TestMethod]
public void DeleteReturnsOk()
{
    // Arrange
    var mockRepository = new Mock<IProductRepository>();
    var controller = new Products2Controller(mockRepository.Object);

    // Act
    IHttpActionResult actionResult = controller.Delete(10);

    // Assert
    Assert.IsInstanceOfType(actionResult, typeof(OkResult));
}

A ação retorna 201 (Criado) com um cabeçalho Location

O Post método chama CreatedAtRoute para retornar uma resposta HTTP 201 com um URI no cabeçalho Location. No teste de unidade, verifique se a ação define os valores de roteamento corretos.

[TestMethod]
public void PostMethodSetsLocationHeader()
{
    // Arrange
    var mockRepository = new Mock<IProductRepository>();
    var controller = new Products2Controller(mockRepository.Object);

    // Act
    IHttpActionResult actionResult = controller.Post(new Product { Id = 10, Name = "Product1" });
    var createdResult = actionResult as CreatedAtRouteNegotiatedContentResult<Product>;

    // Assert
    Assert.IsNotNull(createdResult);
    Assert.AreEqual("DefaultApi", createdResult.RouteName);
    Assert.AreEqual(10, createdResult.RouteValues["id"]);
}

A ação retorna outro 2xx com um corpo de resposta

O Put método chama Content para retornar uma resposta HTTP 202 (Aceito) com um corpo de resposta. Esse caso é semelhante ao retorno de 200 (OK), mas o teste de unidade também deve marcar o código status.

[TestMethod]
public void PutReturnsContentResult()
{
    // Arrange
    var mockRepository = new Mock<IProductRepository>();
    var controller = new Products2Controller(mockRepository.Object);

    // Act
    IHttpActionResult actionResult = controller.Put(new Product { Id = 10, Name = "Product" });
    var contentResult = actionResult as NegotiatedContentResult<Product>;

    // Assert
    Assert.IsNotNull(contentResult);
    Assert.AreEqual(HttpStatusCode.Accepted, contentResult.StatusCode);
    Assert.IsNotNull(contentResult.Content);
    Assert.AreEqual(10, contentResult.Content.Id);
}

Recursos adicionais