Controladores de pruebas unitarias en ASP.NET Web API 2

En este tema, se describen algunas técnicas específicas para controladores de pruebas unitarias en Web API 2. Antes de leer este tema, es posible que quiera leer el tutorial Pruebas unitarias de ASP.NET Web API 2, que muestra cómo agregar un proyecto de pruebas unitarias a la solución.

Versiones de software usadas en el tutorial

Nota:

He usado Moq, pero la misma idea se aplica a cualquier marco de simulación. Moq 4.5.30 (y versiones posteriores) admite Visual Studio 2017, Roslyn y .NET 4.5 y versiones posteriores.

Un patrón común en las pruebas unitarias es "organizar-actuar-afirmar":

  • Organizar: configurar los requisitos previos para que se ejecute la prueba.
  • Actuar: realizar la prueba.
  • Afirmar: comprobar que la prueba se ha realizado correctamente.

En el paso de organización, a menudo usará objetos ficticios o auxiliares. Esto minimiza el número de dependencias, por lo que la prueba se centra en probar una cosa.

Estas son algunas cosas para las que debe realizar pruebas unitarias en los controladores de Web API:

  • La acción devuelve el tipo correcto de respuesta.
  • Los parámetros no válidos devuelven la respuesta de error correcta.
  • La acción llama al método correcto del repositorio o la capa de servicio.
  • Si la respuesta incluye un modelo de dominio, compruebe el tipo de modelo.

Estos son algunos de las cuestiones generales que se deben probar, pero los detalles dependen de la implementación del controlador. Específicamente, hay una gran diferencia si las acciones del controlador devuelven HttpResponseMessage o IHttpActionResult. Para obtener más información sobre estos tipos de resultados, consulte Resultados de acciones en Web Api 2.

Prueba de acciones que devuelven HttpResponseMessage

Este es un ejemplo de un controlador cuyas acciones devuelven 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 el controlador usa la inserción de dependencias para insertar un elemento IProductRepository. Esto hace que controlador tenga más capacidad de prueba, ya que puede insertar un repositorio ficticio. La siguiente prueba unitaria comprueba que el método Get escriba un elemento Product en el cuerpo de la respuesta. Supongamos que repository es un elemento IProductRepository ficticio.

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

Es importante establecer los elementos Request y Configuration en el controlador. De lo contrario, se producirá en la prueba un error ArgumentNullException o InvalidOperationException.

El método Post llama a UrlHelper.Link para crear vínculos en la respuesta. Esto requiere un poco más de configuración en la prueba unitaria:

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

La clase UrlHelper necesita la dirección URL de la solicitud y los datos de la ruta, por lo que la prueba tiene que establecer valores para ellos. Otra opción es un elemento UrlHelper ficticio o auxiliar. Con este enfoque, se reemplaza el valor predeterminado de ApiController.Url por una versión ficticia o de código auxiliar que devuelve un valor fijo.

Vamos a reescribir la prueba mediante el marco Moq. Instale el paquete NuGet Moq en el proyecto de pruebas.

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

En esta versión, no es necesario configurar ningún dato de ruta, ya que el elementoUrlHelper ficticio devuelve una cadena constante.

Prueba de acciones que devuelven IHttpActionResult

En Web API 2, una acción del controlador puede devolver IHttpActionResult, que es análogo a ActionResult en MVC de ASP.NET. La interfaz IHttpActionResult define un patrón de comandos para crear respuestas HTTP. En lugar de crear la respuesta directamente, el controlador devuelve un elemento IHttpActionResult. Más adelante, la canalización invoca a IHttpActionResult para crear la respuesta. Este enfoque facilita la escritura de pruebas unitarias, ya que puede omitir una gran cantidad de la configuración necesaria para HttpResponseMessage.

Este es un controlador de ejemplo cuyas acciones devuelven 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);
    }    
}

En este ejemplo, se muestran algunos patrones comunes con IHttpActionResult. Veamos cómo realizar pruebas unitarias en ellos.

La acción devuelve 200 (OK) con un cuerpo de respuesta

El método Get llama a Ok(product) si se encuentra el producto. En la prueba unitaria, asegúrese de que el tipo de valor devuelto sea OkNegotiatedContentResult y de que el producto devuelto tenga el identificador correcto.

[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 la prueba unitaria no ejecuta el resultado de la acción. Se puede dar por supuesto que el resultado de la acción crea correctamente la respuesta HTTP. (Es por eso que el marco Web API tiene sus propias pruebas unitarias).

La acción devuelve 404 (No encontrado)

El método Get llama a NotFound() si no se encuentra el producto. En este caso, la prueba unitaria solo comprueba si el tipo de valor devuelto es 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));
}

La acción devuelve 200 (OK) sin cuerpo de respuesta

El método Delete llama a Ok() para devolver una respuesta HTTP 200 vacía. Al igual que en el ejemplo anterior, la prueba unitaria comprueba el tipo de valor devuelto, en este 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));
}

La acción devuelve 201 (Creado) con un encabezado de ubicación

El método Post llama a CreatedAtRoute para devolver una respuesta HTTP 201 con un identificador URI en el encabezado de ubicación. En la prueba unitaria, compruebe que la acción establezca los valores de enrutamiento correctos.

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

La acción devuelve otra respuesta 2xx con un cuerpo de respuesta

El método Put llama a Content para devolver una respuesta HTTP 202 (aceptada) con un cuerpo de respuesta. Este caso es similar a devolver 200 (OK), pero la prueba unitaria también debe comprobar el código de estado.

[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 adicionales