Tutorial Práctico de TDD en Proyectos Reales con .NET: Parte I

Implementando TDD en un Escenario de Negocios

Tutorial Práctico de TDD en Proyectos Reales con .NET: Parte I

En el aprendizaje de TDD (Test-Driven Development), es habitual comenzar con ejercicios elementales, como el desarrollo de una calculadora o el cálculo de áreas geométricas. Quiero ser claro en este punto: estos ejercicios no son triviales, sino que son fundamentales para comprender los conceptos clave de TDD y para afianzar una base sólida en la materia. Sin embargo, la simplicidad de estos ejemplos no siempre refleja la complejidad y los matices de los proyectos de software en entornos profesionales. No obstante, antes de adentrarnos en escenarios que desafían nuestra comprensión y aplicación de TDD a un nivel más avanzado, te invito a visitar mi artículo sobre Introducción al TDD: Fundamentos y conceptos, donde se establecen las bases necesarias para aprovechar al máximo las lecciones prácticas que desglosaremos a continuación.

A pesar de la sencillez de los ejercicios iniciales, quiero recalcar su valor intrínseco. Estos no solo sirven como una introducción a TDD, sino que también son una oportunidad para practicar la Práctica deliberada, una técnica que, aplicada correctamente, puede acelerar tu desarrollo profesional en TDD. Para una comprensión más profunda de cómo integrar la práctica deliberada en tu rutina de aprendizaje y elevar tu dominio de TDD, te recomiendo el artículo de Pedro Pardal, Práctica deliberada y TDD. En él encontrarás una guía valiosa para enfocar de manera efectiva el perfeccionamiento de tus habilidades y para comprender cómo los fundamentos que inicialmente parecen básicos son cruciales en tu desarrollo como profesional del software.

El en siguiente artículo vamos a trabajar sobre un requerimiento de negocios más cercano a la realidad, vamos a avanzar aplicando TDD en conjunto con Micro-commits, tal y como sugiero aplicarlo en nuestro día a día. ¡Comencemos!

Ejemplo práctico: Utilizando TDD en un requerimiento de negocios

Cuando trabajamos en el desarrollo de software, a menudo nos enfrentamos a requerimientos que, si bien pueden parecer claros a primera vista, carecen de la profundidad y especificidad necesarias para una implementación directa. Por ejemplo:

Procesamiento de solicitudes de préstamo masivas

Se necesita recibir un lote de solicitudes de préstamo y realizar llamadas a una API de análisis de riesgo por cada solicitud. Según las respuestas, se clasificarán las solicitudes en aprobadas o rechazadas, se enviarán correos electrónicos a cada solicitante con el resultado y se despacharán eventos para los siguientes pasos en el flujo de trabajo del préstamo.

Este requerimiento si bien parece claro, no termina de concretar aspectos vitales tales como:

  • Manejo de errores:

    • ¿Qué hacer si la API falla o si hay un problema al procesar una solicitud?
  • Formatos de entrada/salida:

    • ¿Qué datos son requeridos en cada solicitud de préstamo?

    • ¿Qué argumentos se deben proporcionar a la API de análisis de riesgos?

    • ¿Qué información se espera en la respuesta de la API?

  • Criterios de aceptación:

    • ¿Cuáles son los criterios exactos para aprobar o rechazar una solicitud basándose en la respuesta de la API?

Abordando la ambigüedad con TDD

El aplicar TDD puede ser un salvavidas en estas situaciones. TDD nos insta a comenzar definiendo qué es lo que queremos que nuestro código haga en forma de Tests. Esto requiere analizar y pensar críticamente sobre el requerimiento para identificar y definir esos detalles que no se han especificado.

Proceso TDD sugerido:

  • Análisis y Preguntas: Antes de escribir la primera prueba, enumeramos las preguntas que surgen del requerimiento y buscamos respuestas. Esto puede implicar discusiones con stakeholders o el equipo de negocio.

  • Casos de Prueba Iniciales: Escribimos pruebas para los flujos de trabajo obvios y positivos, identificando los casos de uso más claros del requerimiento.

  • Refinamiento: A medida que avanzamos, identificamos y escribimos pruebas para los casos de uso menos obvios, incluido el manejo de errores y comportamientos inesperados.

  • Especificación: Utilizamos ejemplos concretos para definir los criterios de aceptación en colaboración con los stakeholders.

  • Iteración y Extensión: Con cada nuevo descubrimiento o aclaración, iteramos sobre nuestras pruebas y código para garantizar que seguimos alineados con las expectativas del negocio.

Poniendo en práctica TDD: Aclaremos nuestras dudas.

La aplicación de TDD nos obliga a definir el comportamiento esperado en nuestros tests, antes de siquiera comenzar a escribir la primera línea de código. Supongamos que consultamos con nuestros stakeholders y responden nuestras inquietudes.

  • Manejo de errores:

    • ¿Qué hacer si la API falla o si hay un problema al procesar una solicitud?
      • Si la API falla, debemos implementar un mecanismo de reintento con un número máximo de intentos configurables. Después de exceder el número máximo de intentos, se debe registrar el error y marcar la solicitud como 'pendiente de revisión manual'.
      • En caso de que haya un problema al procesar una solicitud (por ejemplo, un formato de entrada o dato inválido), debemos registrar el error y marcar la solicitud como 'fallida' con un código de error específico que indique la razón del fallo.
  • Formatos de entrada/salida:

    • ¿Qué datos son requeridos en cada solicitud de préstamo?

      • Cada solicitud de préstamo debe contener la información necesaria para identificar al solicitante y evaluar su solicitud. Los datos requeridos incluyen:
        • Identificador único del solicitante (puede ser un número de identificación nacional, número de cliente, etc.)
        • Cantidad solicitada
        • Ingreso mensual
        • Tasa de interés anual
        • Plazo del préstamo en meses
        • Puntuación de crédito
    • ¿Qué argumentos se deben proporcionar a la API de análisis de riesgos?

      • Para realizar un análisis de riesgos adecuado, la API requiere los siguientes argumentos:
        • Identificador único del solicitante
        • Cantidad solicitada
        • Ingreso mensual
        • Puntuación de crédito
        • Edad
        • Estado civil
        • Número de dependientes
        • Duración empleo
    • ¿Qué información se espera en la respuesta de la API?

      • La respuesta de la API de análisis de riesgo incluirá:
        • El nivel de riesgo del préstamo: 'Bajo', 'Medio' o 'Alto'
        • Una sugerencia de decisión: 'Aprobado' o 'Rechazado'
        • Un código de respuesta
          • 000-Ok - En solicitudes aprobadas
          • 001-KO - Puntuación de crédito insuficiente
          • 002-KO - Relación ingreso/deuda elevada
          • 003-KO - Historial crediticio cuestionable
  • Criterios de aceptación:

    • ¿Cuáles son los criterios exactos para aprobar o rechazar una solicitud basándose en la respuesta de la API?

      • Los criterios para aprobar una solicitud se basan en una puntuación de crédito mayor o igual a 700, una relación ingreso/deuda o DTI (Debt-to-Income) menor al 35% y una respuesta satisfactoria de la API de análisis de riesgos.
      • Para las solicitudes rechazadas, se debe proporcionar un código de rechazo que indique la razón (puntuación de crédito insuficiente, relación ingreso/deuda elevada, etc.).

      DTI = (Pago mensual/Ingreso mensual) 100 Pago mensual = (interés mensual Cantidad solicitada) / (1 - (1 + interés mensual)^(- plazo en meses)) Interés mensual = Tasa de interés anual / 12

Poniendo en práctica TDD: ¡Escribamos código!

Antes de zambullirnos en el ejemplo práctico, es crucial que configuremos nuestro entorno de desarrollo adecuadamente. Este paso es esencial para asegurar que puedas seguir el tutorial sin contratiempos y centrarte en aprender TDD de manera efectiva. Te invito a leer el artículo de configuración del entorno donde se realiza la configuración inicial del proyecto paso a paso.

Si prefieres ir directo a la acción, aquí tienes una secuencia de comandos que establecerá tu proyecto rápidamente desde el Git Bash. Recuerda, estos comandos están explicados en detalle en el artículo vinculado, por lo que si encuentras algún paso confuso o deseas entender mejor lo que cada comando hace, te recomiendo visitar el artículo.

# Configura el proyecto de pruebas y la biblioteca de lógica de la aplicación
dotnet new xunit -f net8.0 -n Loan.Domain.Tests -o tests/Loan.Domain.Tests && \
dotnet new classlib -f net8.0 -n Loan.Domain -o source/Loan.Domain && \
# Inicializa una solución y le agrega las referencias a los proyectos
dotnet new sln -n Loan && \
dotnet sln add ./tests/Loan.Domain.Tests/Loan.Domain.Tests.csproj && \
dotnet sln add source/Loan.Domain/Loan.Domain.csproj && \
# Configura git y añade un .gitignore desde el template sugerido de dotnet
dotnet new gitignore && \
git init && \
git add . && \
git commit -m "🎉 Initial project setup." && \
# Añade paquetes que facilitan la configuración y comprobación de las pruebas
dotnet add ./tests/Loan.Domain.Tests/Loan.Domain.Tests.csproj package FluentAssertions -v 6.12.0 && \
dotnet add ./tests/Loan.Domain.Tests/Loan.Domain.Tests.csproj package NSubstitute -v 5.1.0 && \
git commit -am "📦️ Add packages FluentAssertions and NSubstitute

- FluentAssertions package for enhanced assertion capabilities.
- NSubstitute for mocking dependencies in unit tests."

Cuando aplicamos TDD, es importante desglosar la funcionalidad en las unidades más pequeñas posibles - pasos atómicos. En el caso de un sistema de solicitud de préstamos, claramente hay cálculos y operaciones matemáticas críticas. Comenzar por estos cálculos es un enfoque lógico.

El interés mensual es primordial para determinar el pago mensual y, finalmente, el DTI (Debt-to-Income ratio). Empezaremos por escribir una prueba que verifique que calculamos correctamente este valor.

using FluentAssertions;

namespace Loan.Application.Tests;

public class LoanApplicationCalculatorTests
{
    [Fact]
    public void ShouldCalculateMonthlyInterestRate_WhenGivenAnnualInterestRate()
    {
        const decimal monthlyInterestRateExpected = 0.004167m;
        const decimal annualInterestRate = 5m;

        decimal monthlyInterestRate = LoanApplicationCalculator.GetMonthlyInterestRate(annualInterestRate);

        monthlyInterestRate.Should().Be(monthlyInterestRateExpected);
    }
}

public class LoanApplicationCalculator
{
    public static decimal GetMonthlyInterestRate(decimal annualInterestRate)
    {
        throw new NotImplementedException();
    }
}

El primer avance en nuestro desarrollo, aunque modesto, es significativo. Ya hemos determinado que tendremos una clase estática para realizar los cálculos de propiedades que no tienen estado, le hemos asignado un nombre a nuestra clase LoanApplicationCalculator, definimos su rol y responsabilidad en el sistema.

Resguardemos nuestro avance con un commit:

git add . &&
git commit -m "🧪 test(LoanCalculator): Assert calculate monthly interest rate.

Given
- annualInterestRate: 5

MonthlyInterestRate should be 0.004167."

Procedemos a implementar la lógica mínima necesaria para satisfacer nuestro test. Para esto, sencillamente regresamos el monto esperado desde nuestra implementación.

public class LoanApplicationCalculator
{
    public static decimal GetMonthlyInterestRate(decimal annualInterestRate)
    {
        return 0.004167m;
    }
}

Aseguremos nuestro progreso mediante un commit con el siguiente comando:

git commit -am "✅ feat(LoanCalculator): Implement monthly interest rate calculation."

Al añadir otro test para un valor de tasa de interés anual diferente, destacamos la importancia de probar múltiples escenarios:

[Fact]
public void ShouldCalculateMonthlyInterestRate_WhenGivenAnnualInterestRateDifferent()
{
    const decimal monthlyInterestRateExpected = 0.003333m;
    const decimal annualInterestRate = 4m;

    decimal monthlyInterestRate = LoanApplicationCalculator.GetMonthlyInterestRate(annualInterestRate);

    monthlyInterestRate.Should().Be(monthlyInterestRateExpected);
}

Aseguremos nuestro nuevo test con el siguiente commit:

git commit -am "🧪 test(LoanCalculator): Add another test for monthly interest rate calculation with different input.

Given
- annualInterestRate: 4 

MonthlyInterestRate should be 0.003333."

Ahora implementamos la lógica de manera generalizada para cumplir con ambos tests y calcular el MonthlyInterestRate en nuestra clase LoanApplicationCalculator.

public static decimal GetMonthlyInterestRate(decimal annualInterestRate)
{
    return Math.Round(annualInterestRate / 1_200, 6);
}

Y un commit más para este avance significativo:

git commit -am "✅ feat(LoanCalculator): Add basic implementation for monthly interest rate calculation."

Llegados a este punto ya hemos cubierto las dos primeras partes del ciclo RGR, así que continuemos con el ♻️ Refactor. Observamos que tenemos lo que podemos considerar Magic Numbers, que serían los números 1.200 y 6. Vamos a cambiarlos por constantes para hacer nuestro código más legible.

public class LoanApplicationCalculator
{
    private const decimal MonthsInYear = 12;
    private const decimal PercentNumber = 100m;
    private const int RoundNumber = 6;

    public static decimal GetMonthlyInterestRate(decimal annualInterestRate) =>
        Math.Round(decimal.Divide(decimal.Divide(annualInterestRate, PercentNumber), MonthsInYear), RoundNumber);
}

Finalmente, aseguramos nuestros cambios con el siguiente commit: git commit -am "♻️ LoanApplicationCalculator: Substitute magic numbers with named constants for enhanced code readability.".

Para asegurar que nuestra implementación y cálculo son robustos, vamos a cubrir diferentes escenarios utilizando el Attribute Theory e InlineData, así que, cambiemos nuestros tests actuales por lo siguiente:

[Theory]
[InlineData(5, 0.004167)]
[InlineData(4, 0.003333)]
[InlineData(10, 0.008333)]
public void ShouldCalculateMonthlyInterestRate_WhenGivenAnnualInterestRate(decimal annualInterestRate, decimal monthlyInterestRateExpected)
{
    decimal monthlyInterestRate = LoanApplicationCalculator.GetMonthlyInterestRate(annualInterestRate);

    monthlyInterestRate.Should().Be(monthlyInterestRateExpected);
}

Y aprovechemos de remover nuestro test ShouldCalculateMonthlyInterestRate_WhenGivenAnnualInterestRateDifferent, ya que, nuestro Theory cubre el escenario que antes cubría nuestro unit test.

Nuevamente, aseguremos los cambios con un commit: git commit -am "♻️ LoanApplicationCalculator: Consolidate interest rate tests with Theory data-driven approach.".

Habiendo establecido cómo calcular el interés mensual, un componente crítico en la evaluación de las solicitudes de préstamo, nuestro próximo paso es determinar el monto del pago mensual. Este cálculo es esencial, ya que influirá directamente en el cálculo del DTI y, por consiguiente, en la decisión final de aprobar o rechazar una solicitud de préstamo. La fórmula que nos han proporcionado para el pago mensual es Pago mensual = (interés mensual * Cantidad solicitada) / (1 - (1 + interés mensual)^(- plazo en meses)). Con esta fórmula en mano, es hora de abordar la implementación de este cálculo crucial.

Adelantándonos a la necesidad de garantizar que diferentes entradas produzcan los resultados esperados, considero prudente diseñar nuestra prueba desde el comienzo utilizando Theory e InlineData.

//Test para nuestra clase LoanApplicationCalculatorTests
[Theory]
[InlineData(10_000, 0.004167, 24, 438.72)]
public void ShouldCalculateMonthlyPayment_WhenCalledWithStandardParameters(decimal amountRequested, decimal monthlyInterestRate, int termInMonths, decimal monthlyPaymentExpected)
{
    decimal monthlyPayment = LoanApplicationCalculator.GetMonthlyPayment(amountRequested, monthlyInterestRate, termInMonths);

    monthlyPayment.Should().Be(monthlyPaymentExpected);
}

//Implementación para nuestra clase LoanApplicationCalculator
public static decimal GetMonthlyPayment(decimal amountRequested, decimal monthlyInterestRate, int termInMonths)
{
    throw new NotImplementedException();
}

Como de costumbre, resguardemos nuestro avance con un commit:

git commit -am "🧪 test(LoanCalculator): Assert calculate monthly payment.

Given
- amountRequested: 10.000
- monthlyInterestRate: 0.004167
- termInMonths: 24

MonthlyPayment should be 438.72."

Para implementar el cálculo del pago mensual, tendremos la necesidad de calcular una potencia. Lamentablemente en C#, no conozco un método que me permita calcular el POW de un decimal, así que tendremos que crear nuestro propio método para elevar un número a una potencia dada.

Podriamos utilizar el método POW de C#, sin embargo, este opera con double y la conversión a decimal puede acarrear imprecisiones, lo que no es deseable en cálculos financieros donde la precisión del tipo decimal es crucial.

//Actualización de nuestro método GetMonthlyPayment
public static decimal GetMonthlyPayment(decimal amountRequested, decimal monthlyInterestRate, int termInMonths)
{
    decimal dividend = monthlyInterestRate * amountRequested;
    decimal divisor = 1 - MathExtensions.Pow(1 + monthlyInterestRate, -1 * termInMonths);
    return Math.Round(dividend / divisor, 2);
}

//Incorporación de nuestro extension method para calcular una potencia
public static class MathExtensions
{
    public static decimal Pow(decimal @base, int exponent)
    {
        if (@base == 0 && exponent <= 0)
        {
            if (exponent < 0)
            {
                throw new DivideByZeroException("Cannot raise zero to a negative power.");
            }

            return 1;
        }

        decimal result = 1;

        for (var i = 0; i < Math.Abs(exponent); i++)
        {
            result *= @base;
        }

        return exponent > 0 ? result : 1 / result;
    }
}

Nuevamente resguardamos nuestro avance con un commit: git commit -am "✅ LoanApplicationCalculator: Implement logic by calculate the monthly payment."

Para asegurar que nuestra implementación y cálculo son robustos, vamos a agregar dos escenarios más a nuestro test ShouldCalculateMonthlyPayment_WhenCalledWithStandardParameters.

[Theory]
[InlineData(10_000, 0.004167, 24, 438.72)]
[InlineData(10_000, 0.003333, 24, 434.25)]
[InlineData(100_000_000_000, 0.001875, 360, 382_246_102.27)]
public void ShouldCalculateMonthlyPayment_WhenCalledWithStandardParameters(decimal amountRequested, decimal monthlyInterestRate, int termInMonths, decimal monthlyPaymentExpected)
{
    decimal monthlyPayment = LoanApplicationCalculator.GetMonthlyPayment(amountRequested, monthlyInterestRate, termInMonths);

    monthlyPayment.Should().Be(monthlyPaymentExpected);
}

Resguardemos nuestro avance con un commit:

git commit -am "🧪 test(LoanCalculator): Expand unit tests for monthly payment calculation

Given
- amountRequested: 10,000-
- monthlyInterestRate: 0.003333
- termInMonths: 24

ExpectedMonthlyPayment: 434.25

Given
- amountRequested: 100,000,000,000
- monthlyInterestRate: 0.001875
- termInMonths: 360

ExpectedMonthlyPayment: 382,246,102.27"

En este punto ya hemos avanzado bastante en el cálculo de las diferentes operaciones matemáticas críticas para nuestro sistema de solicitud de préstamos, nos faltaría finalmente asegurar el cálculo de DTI (Debt-to-Income), para ello, como de costumbre comencemos por nuestro Unit Test.

//Test para nuestra clase LoanApplicationCalculatorTests
[Theory]
[InlineData(1_900, 438.71, 23.09)]
public void ShouldCalculateDebtToIncome_WhenGivenMonthlyIncomeAndMonthlyPayment(decimal monthlyIncome, decimal monthlyPayment, decimal debtToIncomeExpected)
{
    decimal debtToIncome = LoanApplicationCalculator.GetDebtToIncome(monthlyIncome, monthlyPayment);

    debtToIncome.Should().Be(debtToIncomeExpected);
}

//Implementación para nuestra clase LoanApplicationCalculator
public static decimal GetDebtToIncome(decimal monthlyIncome, decimal monthlyPayment)
{
    throw new NotImplementedException();
}

Nuevamente hacemos un commit:

git commit -am "🧪 test(LoanCalculator): Assert calculate Debt to Income.

Given
- monthlyIncome: 1.900
- monthlyPayment: 438.71

DebtToIncome should be 23.09."

Con este segmento ya cubierto, vamos a escribir la implementación que cubra nuestro unit test.

public static decimal GetDebtToIncome(decimal monthlyIncome, decimal monthlyPayment) =>
    Math.Round((monthlyPayment / monthlyIncome) * 100m, 2);

Guardemos nuestro avance: git commit -am "✅ feat(LoanCalculator): Implement logic by calculate the debt to Income from MonthlyIncome and MonthlyPayment."

Y nuevamente, aseguremos que nuestra implementación y cálculo son robustos, para ellos agreguemos dos escenarios más a nuestro test ShouldCalculateDebtToIncome_WhenGivenMonthlyIncomeAndMonthlyPayment.

[Theory]
[InlineData(1_900, 438.71, 23.09)]
[InlineData(1_900, 917.39, 48.28)]
[InlineData(1_200, 496.92, 41.41)]
public void ShouldCalculateDebtToIncome_WhenGivenMonthlyIncomeAndMonthlyPayment(decimal monthlyIncome, decimal monthlyPayment, decimal debtToIncomeExpected)
{
    decimal debtToIncome = LoanApplicationCalculator.GetDebtToIncome(monthlyIncome, monthlyPayment);

    debtToIncome.Should().Be(debtToIncomeExpected);
}

En este punto, todos los tests deben estar ✅ así que nuevamente commit:

git commit -am "🧪 test(LoanCalculator): Expand unit tests for Debt To Income calculation

Given
- monthlyIncome: 1.900
- monthlyPayment: 917,39

 ExpectedDebtToIncome: 48,28

 Given
 - monthlyIncome: 1.900
 - monthlyPayment: 496,92

 ExpectedDebtToIncome: 41,41"

Avanzamos en el ciclo Red-Green-Refactor (RGR) de Loan.Domain, y es momento de refactorizar. Hasta ahora, por cuestiones de conveniencia, hemos mantenido todas nuestras clases e interfaces en un solo archivo. Es hora de organizar mejor nuestro código: vamos a trasladar estas clases e interfaces desde el proyecto de tests a la solución de implementación, ubicando cada una en su propio archivo.

Este proceso implica una actualización de las referencias del proyecto. Puedes agregar la referencia al proyecto de implementación desde tu IDE o mediante la línea de comandos con la siguiente instrucción:

dotnet add tests/Loan.Domain.Tests/Loan.Domain.Tests.csproj reference source/Loan.Domain/Loan.Domain.csproj

Después de mover los archivos, ejecutamos los tests para confirmar que todo sigue funcionando correctamente. Si los tests son exitosos, aseguramos los cambios con el siguiente commit:

git commit -am "🚚 refactor(structure): Separate classes and interfaces into individual files in the implementation project."

Conclusion y próximos pasos

Hasta ahora, hemos abordado la primera parte de los cálculos financieros críticos requeridos, desde el cálculo de pagos mensuales hasta el ratio de deuda sobre ingresos (DTI).

En nuestro próximo artículo, continuaremos desarrollando este tema para completar los requerimientos pendientes. Además, evaluaremos posibles mejoras en nuestra implementación actual, como reducir el alto acoplamiento en nuestro código por el uso de métodos estáticos para los cálculos.

Me interesaría mucho conocer tus opiniones o preguntas acerca de estos temas. No dudes en enviar tus comentarios o preguntas, y podré incluirlos en la próxima entrega.

Fuentes

  • Hablemos de TDD, Test-Driven Development, una práctica habitual en equipos de desarrollo ágiles. Disponible en YouTube
  • Exeal (2023, October). Práctica deliberada: una forma alternativa de aprender TDD. Disponible en Exeal.com
  • EnZona (2022, January). Práctica deliberada. Disponible en Enzona.es
  • Exeal (2023, December). La base de la integración continua: los micro-commits. Disponible en Exeal.com
  • TDD (Test Driven Development). Desarrollo dirigido por pruebas en Software Crafters. Disponible en SoftwareCrafters.com
  • Introducción al TDD: Fundamentos y conceptos. Disponible en henksandoval.com