Ya desde las primeras versiones, .NET Core utiliza inyección de dependencias desde sus cimientos, y provee un completo mecanismo de Inversión de Control (IoC) para hacernos la vida más simple.

Podemos registrar cualquier clase y delegar en el framework la tarea de generar instancias e inyectar los objetos, y la clase HttpClient no es la excepción. Ahora, cómo inyectamos HttpClient fakes?

Ahora veremos todo lo necesario para poder escribir buenos test unitarios con excelentes HttpClientfakeados“!

Un fake es un objeto usamos para simular un comportamiento específico. Es uno de los conceptos de las pruebas unitarias.

Contexto

Hemos estado trabajando en un nuevo servicio que realiza una petición HTTP como parte de las reglas de negocio.

public class StockService
{
    private readonly HttpClient _httpClient;

    public StockService(HttpClient httpClient) =>
        _httpClient = httpClient;

    public async Task<string> IsAvailableAsync(bool check)
    {
        if (check == false)
            return "N";

        var result = await _httpClient.GetAsync("stock");

        // To keep it simple, we ignore other results
        return result.StatusCode switch
        {
            HttpStatusCode.OK => "S",
            HttpStatusCode.BadRequest => "E"
        };
    }
}

A su vez, registramos el HttpClient en Startup.ConfigureServices() donde además configuramos la dirección base del servidor.

public void ConfigureServices(IServiceCollection services)
{
    // Other registrations...
    services.AddHttpClient<StockService>(client =>
    {
        // For demo only. Use settings instead
        client.BaseAddress = new Uri("https://api.facutherock.net");
    });
}

Necesitamos asegurarnos de que este servicio se comporte acorde a la especificación.

Requerimientos

Queremos escribir buenos test unitarios para asegurarnos que el servicio StockService.IsAvailableAsync() cumpla con los siguientes requerimientos:

  1. Retornar "N" si el parámetro check es igual a false.
  2. Si el parámetro check es igual a true y el resultado de la petición es exitosa (200 – Ok) devolver "S".
  3. Si el parámetro check es igual a true pero el resultado de la petición es de error (400 – BadRequest) devolver "E".

Si bien los requerimientos son muy simples, nos van a servir para demostrar los aspectos que necesitamos.

Desafío

A primera vista podríamos intentar utilizar un HttpClient directamente, cómo en el siguiente ejemplo:

[Fact]
public async Task BadTest()
{
    // Arrange
    var httpClient = new HttpClient();
    var sut = new StockService(httpClient);

    // Act
    var result = await sut.IsAvailableAsync(true);

    // Assert
    Assert.Equal("S", result);
}

Sin embargo, ese test fallaría ya que intentaría hacer una petición a un servidor inválido. Incluso si no fallase, implicaría una petición real, lo cuál no es recomendable para un test unitario.

El desafío consiste en crear una versión del HttpClient que simule hacer la petición, pero que realmente no lo haga.

Aspectos técnicos

La clase HttpClient realiza las peticiones utilizando un HttpMessageHandler de base, el cuál expone un método llamado SendAsync.

La manera en la que podemos simular determinados comportamientos es sobrescribiendo el método anterior. Para esto necesitamos generar nuestra propia subclase.

Podremos inyectar nuestra subclase utilizando una de las sobrecargas que la clase HttpClient tiene aceptando una instancia de HttpMessageHandler.

Tiempo de codear

Vamos a empezar a escribir los test necesarios para asegurar el buen funcionamiento de nuestro servicio.

La estrategia consiste en ir generando subclases de HttpMessageHandler que respondan siempre de la misma forma y cuya respuesta sea controlada por nosotros.

Requerimiento 1

El test para este requerimiento es el más simple de implementar, ya que en este caso no necesitamos utilizar el HttpClient para la petición.

Podemos utilizar directamente una nueva instancia de HttpClient:

[Fact]
public async Task FirstRequirementTest()
{
    // Arrange
    var httpClient = new HttpClient();
    var sut = new StockService(httpClient);

    // Act
    var result = await sut.IsAvailableAsync(false);

    // Assert
    Assert.Equal("N", result);
}

Requerimiento 2

Este test es un poco más complejo, ya que necesitamos que la instancia de HttpClient devuelva un mensaje de retorno exitoso.

Para esto, crearemos una subclase de HttpMessageHandler y sobrescribiremos el método SendAsync para devolver siempre un resultado exitoso.

public class OkHttpMessageHandler : HttpMessageHandler
{
    protected override Task<HttpResponseMessage> SendAsync(
        HttpRequestMessage request,
        CancellationToken cancellationToken)
    {
        var okResult = new HttpResponseMessage
        {
            StatusCode = System.Net.HttpStatusCode.OK
        };

        return Task.FromResult(okResult);
    }
}

Finalmente crearemos una instancia de un HttpClient y completaremos el test.

[Fact]
public async Task SecondRequirementTest()
{
    // Arrange
    var okHttpMessageHandler = new OkHttpMessageHandler();
    using var httpClient = new HttpClient(okHttpMessageHandler)
    {
        BaseAddress = new Uri("http://fake.server")
    };
    var sut = new StockService(httpClient);

    // Act
    var result = await sut.IsAvailableAsync(true);

    // Assert
    Assert.Equal("S", result);
}

Requerimiento 3

Este test es exactamente al anterior, solo que la respuesta del HttpClient de ser de error.

public class BadRequestHttpMessageHandler : HttpMessageHandler
{
    protected override Task<HttpResponseMessage> SendAsync(
        HttpRequestMessage request,
        CancellationToken cancellationToken)
    {
        var badRequestResult = new HttpResponseMessage
        {
            StatusCode = System.Net.HttpStatusCode.BadRequest
        };

        return Task.FromResult(badRequestResult);
    }
}

Y el test queda de la siguiente forma:

[Fact]
public async Task ThirdRequirementTest()
{
    // Arrange
    var badRequestHttpMessageHandler = new BadRequestHttpMessageHandler();
    using var httpClient = new HttpClient(badRequestHttpMessageHandler)
    {
        BaseAddress = new Uri("http://fake.server")
    };
    var sut = new StockService(httpClient);

    // Act
    var result = await sut.IsAvailableAsync(true);

    // Assert
    Assert.Equal("E", result);
}

Conclusión

Para escribir buenos test unitarios en casos donde se utiliza la clase HttpClient, es necesario tener control total de las respuesta que el mismo retorna.

En este post vimos como utilizar HttpMessageHandler para simular el comportamiento deseado y asegurar un correcto funcionamiento.