Hablemos sobre .Net y Azure

Etiqueta: Arquitectura

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.

Manejo de excepciones sin excepciones con .NET 5

Uno de los aspectos más importantes en el desarrollo de aplicaciones es el manejo de excepciones. Esto es verdadero independientemente del tipo de aplicación o plataforma.

Es habitual que incluso diseñemos reglas de negocio en forma de excepciones, por ejemplo para validar parámetros de entrada o ejecutar acciones no permitidas sobre algunas entidades de dominio.

Algo negativo o falla de diseño que tiene esta forma de manejar excepciones es que no es para nada declarativa. C# no provée ningún mecanismo para hacer explícito qué excepciones puede arrojar un método.

En esta sesión veremos una alternativa para solucionar esa falla de diseño apoyándonos en una excelente librería llamada OneOf.

Manejo de excepciones sin excepciones

Como siempre, podrán encontrar el código completo de todos los ejemplos en mi GitHub.

© 2021 Facu The Rock

Tema por Anders NorenArriba ↑