Hablemos sobre .Net y Azure

Etiqueta: Test Unitarios

Unit-Testeando HttpClient con .NET 5

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.

Código más testeable con ISystemClock

Te propongo examinar juntos el siguiente fragmento de código:

public class BirthDayCalculator
{
    public int CalculateNextBirthDayInDays(
        int month,
        int day)
    {
        var today = DateTime.UtcNow;
        var birthDay = new DateTime(today.Year, month, day);

        if (birthDay < today)
            birthDay = birthDay.AddYears(1);

        return (int)(birthDay -  today).TotalDays;
    }
}

A primera vista se ve bien, dados un mes y un día, el método CalculateNextBirthDayInDays() calcula y retorna la cantidad de días restantes para el próximo cumpleaños.

Sin embargo, algo no huele del todo bien. Ese método no soporta test unitarios.

Problema

Dos de los requesitos estrictamente necesarios de un test unitario, es que debe tener absoluto control del contexto de ejecución y debe ser repetible.

La solución que vimos en la sección anterior, funciona, pero depende de la propiedad estática DateTime.UtcNow. Debido a que no podemos controlar los valores que dicha propiedad puede tomar, no tenemos forma de escribir casos de prueba controlados y repetibles.

Alternativa poco elegante

Una alternativa simple al problema planteado es que el método CalculateNextBirthDayInDays() reciba un parámetro adicional representando la fecha y hora actual. Algo más o menos así:

public class BirthDayCalculator
{
    public int CalculateNextBirthDayInDays(
        int month,
        int day,
        DateTime today)
    {
        var birthDay = new DateTime(today.Year, month, day);

        if (birthDay < today)
            birthDay = birthDay.AddYears(1);

        return (int)(birthDay -  today).TotalDays;
    }
}

Esta modificación, poco elegante pero efectiva, nos permite escribir pruebas como la siguiente:

[Fact]
public void CalculateNumberOfDays()
{
    // Arrange
    var sut = new BirthDayCalculator();

    // Act
    var actualDays = sut.CalculateNextBirthDayInDays(
        month: 3,
        day: 13, 
        today: DateTime.UtcNow);

    // Assert
    Assert.Equal(290, actualDays);
}

Esta implementación tiene los siguientes puntos en contra:

  • No tenemos una forma común o servicio para obtener la fecha actual en forma testeable y centralizada, generando casi con seguridad código repetido.
  • Cada vez que es necesario realizar cálculos con fechas, necesitamos agregar un parámetro adicional. Incluso en aquellos casos donde se haga uso de del método CalculateNextBirthDayInDays().
public int ConsumingCode(DateTime today)
{
    int month = 3, day = 13;
    var calculator = new BirthDayCalculator();

    return calculator.CalculateNextBirthDayInDays(
        month,
        day,
        today);
}

ISystemClock al rescate

El equipo de Microsoft ha creado una bonita interfaz junto con una implementación estándar que se encuentra disponible en el paquete Microsoft.AspNetCore.Authentication llamada ISystemClock y SystemClock respectivamente.

Esta interfaz nos permite solucionar los dos puntos anteriores. Por un lado, no necesitamos agregar parámetros extras a todos los métodos, por el otro, nos permite utilizar inyección de dependencias para centralizar una única implementación.

namespace Microsoft.AspNetCore.Authentication
{
    public interface ISystemClock
    {
        DateTimeOffset UtcNow { get; }
    }
}

¿Cómo la usamos?

Vamos a modificar la clase BirthDayCalculator para recibir por parámetro en el constructor mediante inyección de dependencias la interfaz ISystemClock.

Luego simplemente reemplazamos la llamada a DateTime.UtcNow por _systemClock.UtcNow.

public class BirthDayCalculator
{
    private readonly ISystemClock _systemClock;

    public BirthDayCalculator(ISystemClock systemClock)
    {
        _systemClock = systemClock;
    }

    public int CalculateNextBirthDayInDays(
        int month,
        int day)
    {
        var today = _systemClock.UtcNow;
        var birthDay = new DateTime(today.Year, month, day);

        if (birthDay < today)
            birthDay = birthDay.AddYears(1);

        return (int)(birthDay - today).TotalDays;
    }
}

¿Cómo lo testeamos?

Primero generaremos una clase fake, la cuál llamaremos FakeSystemClock, que implemente la interfaz y que reciba mediante un parámetro en el constructor la fecha y hora a retornar.

public class FakeSystemClock : ISystemClock
{
    private readonly DateTime _desiredDay;

    public FakeSystemClock(DateTime desiredDay)
    {
        _desiredDay = desiredDay;
    }

    public DateTimeOffset UtcNow => _desiredDay;
}

Finalmente, modificamos el test para generar una nueva instancia de FakeSystemClock y pasamos esa instancia a nuestro BirthDayCalculator.

[Fact]
public void CalculateNumberOfDays()
{
    // Arrange
    var desiredDay = new DateTime(2021, 5, 26, 10, 0, 0);
    var fakeSystemClock = new FakeSystemClock(desiredDay);
    var sut = new BirthDayCalculator(fakeSystemClock);

    // Act
    var actualDays = sut.CalculateNextBirthDayInDays(
        month: 3,
        day: 13);

    // Assert
    Assert.Equal(290, actualDays);
}

Si estuviésemos utilizando librerías como Moq o FakeItEasy, no necesitaríamos crear esta clase fake.

ISystemClock en Asp.Net Core

Si estamos en el contexto de aplicaciones Asp.Net Core, podemos utilizar la implementación estándar provista como parte del paquete.

Sólo necesitamos registrarla en nuestro contenedor IoC.

public void ConfigureServices(IServiceCollection services)
{
    services.AddScoped<ISystemClock, SystemClock>();

    // More services
    services.AddControllers();
}

Conclusión

De una forma muy simple y con poco refactoring, podemos eliminar las dependencias a DateTime.UtcNow y hacer nuestro código testeable gracias a ISystemClock.

Podrás encontrar el código completo de ejemplo en mi GitHub.

© 2021 Facu The Rock

Tema por Anders NorenArriba ↑