Tutorial Práctico de TDD en Proyectos Reales con .NET: Parte II
Aprende a implementar Interfaces y NSubstitute en tus pruebas de .NET
Introducción
En el artículo anterior Cómo Implementar TDD en Proyectos Reales con .NET: Guía Práctica (Parte I), nos centramos en los cálculos financieros críticos necesarios para procesar la solicitud de préstamo. Definimos los unit tests que nos ayudan a asegurar el cálculo correcto para el interés mensual, el pago mensual y la ratio de deuda sobre ingresos (Debt To Income DTI). Estos cálculos son clave, ya que, influyen directamente en la decisión de aprobar o rechazar un préstamo.
Resumen de Criterios de Aceptación:
Recordemos los criterios para aprobar una solicitud de préstamo, detallados en el artículo anterior:
Una puntuación de crédito mayor o igual a 700.
Un DTI menor al 35%.
Una respuesta satisfactoria de la API de análisis de riesgos.
Ahora que recordamos los criterios de aceptación, implementemos la lógica utilizando TDD.
Avanzando con la Lógica de Aprobación de Solicitudes
Teniendo en cuenta que previamente trabajamos en asegurar los cálculos financieros necesarios para procesar la solicitud de préstamo. Considero que el siguiente paso lógico en nuestro desarrollo bajo TDD es diseñar la clase que decide si una solicitud es aprobada o rechazada. Me atrevería a decir que este componente es el core del sistema, ya que será el encargado de determinar y aplicar los diferentes criterios de negocio establecidos.
Enfocarnos primeramente en este componente, nos permite centrarnos inicialmente en la lógica de negocio, sin complicarnos con la integración de agentes externos. Al diseñar primero esta lógica de decisión, podemos crear un código robusto que luego nos facilitará la integración de llamadas externas a la API de análisis de riesgos, tarea que podremos abordar una vez que este componente seá sólidamente definido y probado.
En las siguientes secciones, podremos ver cómo estructurar la clase de aprobación usando TDD, cómo definir y escribir los unit tests antes de implementar la funcionalidad, y como simular las respuestas de la API de análisis de riesgos de forma efectiva dentro de nuestro sistema.
Poniendo en práctica TDD: ¡Escribamos código!
Personalmente sugiero comenzar construyendo el camino feliz (happy path), es decir, el camino donde todo funciona según las reglas de negocio establecidas. Esto nos permite entregar valor de negocio (business value) de manera inmediata, aunque es importante recordar que este enfoque inicialmente omite la consideración de posibles escenarios de error, escenarios que debemos abordar más adelante. Una vez que el "happy path" esté implementado, tendremos que fortalecer nuestro sistema incorporando los (edge cases).
Dicho esto, iniciemos escribiendo un unit test que asegure que una solicitud de crédito sea aprobada cuando un cliente tiene una puntuación de crédito mayor o igual a 700, un DTI inferior al 35%, y recibe una respuesta positiva de la API de análisis de riesgos.
Sin embargo, enfrentamos un desafío con nuestra estructura actual. Al comenzar a escribir el test, nos percatamos rápidamente que tendremos una dependencia con la clase LoanApplicationCalculator
, quién será la responsable de proveernos del DTI (Deb to Income). Sin embargo, debido a que en la implementación anterior utilizamos static methods, hemos creado un alto grado de acoplamiento en el sistema, lo que nos dificultará la escritura del nuevo test. Es aquí donde el poder de las interfaces se vuelve crucial. En palabras de Uncle Bob, Depend upon Abstractions. Do not depend upon concretions. o dicho en Español, "Depende de Abstracciones, no de concreciones". Definiremos interfaces para esos servicios externos y nos saldremos con la nuestra, burlando al sistema. (risa maligna)
Al escribir nuestro test el primer obstaculo que encontramos es la necesidad de contar con diferentes servicios adicionales, tales como, ILoanApplicationCalculator
y IRiskAnalysisService
. Para sortear este obstaculo, utilizaremos la biblioteca NSubstitute, con ella podremos crear mocks que simulen el comportamiento de estas dependencias, nos permitan realizar llamadas especificas y manipular los resultados de forma controlada y predecible.
using FluentAssertions;
using NSubstitute;
namespace Loan.Domain.Tests;
public class LoanApplicationProcessorTests
{
[Fact]
public async Task ShouldApproveLoanRequest_WhenAllCriteriaAreMet()
{
var loanApplication = new LoanApplication(10_000, 5, 36, 1_500, 800);
var riskAnalyzeService = Substitute.For<IRiskAnalysisService>();
var calculator = Substitute.For<ILoanApplicationCalculator>();
// Configuramos los mocks para devolver valores que deberían pasar la validación
calculator.GetDebtToIncome(loanApplication).Returns(30);
riskAnalyzeService.AnalyzeAsync().Returns(true);
// Creamos una instancia del procesador con las dependencias simuladas
var processor = new LoanApplicationProcessor(
calculator,
riskAnalyzeService);
bool result = await processor.AnalyzeAsync(loanApplication);
result.Should().BeTrue();
calculator.Received(1).GetDebtToIncome(loanApplication);
await riskAnalyzeService.Received(1).AnalyzeAsync();
}
}
public record LoanApplication(
decimal AmountRequested,
decimal AnnualInterestRate,
int TermInMonths,
decimal MonthlyIncome,
int CreditScore);
public interface IRiskAnalysisService
{
Task<bool> AnalyzeAsync();
}
public interface ILoanApplicationCalculator
{
decimal GetDebtToIncome(LoanApplication loanApplication);
}
public class LoanApplicationProcessor
{
public LoanApplicationProcessor(
ILoanApplicationCalculator calculator,
IRiskAnalysisService riskAnalyzeService)
{
throw new NotImplementedException();
}
public async Task<bool> AnalyzeAsync(LoanApplication loanApplication)
{
throw new NotImplementedException();
}
}
Analizando la implementación del código anterior, primero podemos observar la importancia de utilizar interfaces para desacoplar las dependencias en nuestros unit tests. Al reemplazar la dependencia directa con la clase LoanApplicationCalculator
por una interfaz ILoanApplicationCalculator
, hemos evitado utilizar un componente rígido y difícil de probar, por uno flexible y testeable. Esta estrategia facilita la escritura del test y mejora la mantenibilidad y escalabilidad del código.
Además, al utilizar mocks para los servicios IRiskAnalysisService
y ILoanApplicationCalculator
mediante NSubstitute, hemos podido simular el comportamiento de nuestro sistema sin la necesidad de interactuar con los componentes reales. Configuramos calculator.GetDebtToIncome(loanApplication)
para que devuelva 30 y riskAnalyzeService.AnalyzeAsync()
para que responda true. De esta manera, aseguramos que bajo condiciones ideales (es decir, cuando todos los criterios de aprobación están satisfechos), el procesador de préstamos apruebe la solicitud.
Por último, pero no menos importante, verificamos que nuestros mocks sean utilizados adecuadamente durante la implementación y que reciban los parámetros establecidos. Mediante calculator.Received(1).GetDebtToIncome(loanApplication)
, confirmamos que se haya llamado una vez al método GetDebtToIncome
con el parámetro esperado. Asimismo, con riskAnalyzeService.Received(1).AnalyzeAsync()
, garantizamos que el método AnalyzeAsync
de riskAnalyzeService
también se haya invocado una vez.
Llegados a este punto nuestro test debe fallar, pero podemos resguardar los avances con un commit.
git commit -am "🧪 test(LoanProcessor): Assert that a LoanApplicationRequest is approved when all criterias are met."
Ahora escribamos el mínimo código posible para cubrir nuestro test:
public class LoanApplicationProcessor
{
private readonly ILoanApplicationCalculator _calculator;
private readonly IRiskAnalysisService _riskAnalyzeService;
public LoanApplicationProcessor(
ILoanApplicationCalculator calculator,
IRiskAnalysisService riskAnalyzeService)
{
_calculator = calculator;
_riskAnalyzeService = riskAnalyzeService;
}
public async Task<bool> AnalyzeAsync(LoanApplication loanApplication)
{
if (loanApplication.CreditScore <= 700)
{
return false;
}
decimal debtToIncome = _calculator.GetDebtToIncome(loanApplication);
if (debtToIncome > 35m)
{
return false;
}
bool isApproved = await _riskAnalyzeService.AnalyzeAsync();
return isApproved;
}
}
La implementación del código para el happy path es relativamente sencilla, pero nuestras assertions son robustas y proporcionan un control exhaustivo. Te invito a realizar una prueba adicional: define una nueva instancia de LoanApplication
y pásala como parámetro al método GetDebtToIncome
. Observarás que el test fallará, lo cual es esperado. Este comportamiento no solo verifica que la respuesta esperada sea True, sino que, además, confirma que los servicios externos se invocan de manera adecuada con los parámetros esperados. Así, nuestras pruebas no solo evalúan los resultados esperados, sino también la integración correcta con las dependencias externas.
Como de costumbre, en este punto aconsejo que hagamos un commit para resguardar nuestro avance, baby steps ¡recuerda!
git commit -am "✅ feat(LoanProcessor): Add logic to determine when a LoanRequest meets all criteria and should be approved."
Habiendo cubierto el happy path, hemos asegurado que una solicitud es aprobada al cumplir con todos los críterios. Ahora, podemos abordar los diferentes escenarios en que se rechaza una solicitud. Comencémos garantizando que una solicitud es rechazada al tener un credit score inferior a 700.
[Fact]
public async Task ShouldDenyLoanRequest_WhenCreditScoreBelowMinimum()
{
const int lowCreditScore = 699;
var loanApplication = new LoanApplication(
10_000, 5, 36, 1_500, lowCreditScore);
var riskAnalyzeService = Substitute.For<IRiskAnalysisService>();
var calculator = Substitute.For<ILoanApplicationCalculator>();
var processor = new LoanApplicationProcessor(
calculator,
riskAnalyzeService);
bool result = await processor.AnalyzeAsync(loanApplication);
result.Should().BeFalse();
calculator.DidNotReceive().GetDebtToIncome(Arg.Any<LoanApplication>());
riskAnalyzeService.DidNotReceive().AnalyzeAsync();
}
Con este test, estamos asegurando que al tener un credit score por debajo del umbral requerido de 700, el sistema rechazará la solicitud de prestamo. Adicionalmente, garantizamos que no se invocan servicios adicionales calculator
y riskAnalyzeService
, garantizando que la solicitud sea rechazada automáticamente sin desperdiciar recursos en análisis innecesarios.
git commit -am "✅ test(LoanProcessor): Implement logic to deny a LoanRequest when the credit score falls below 700."
Continuemos con un unit test que asegure que una solicitud es rechazada cuando el DTI es superior al 35%.
[Fact]
public async Task ShouldDenyLoanRequest_WhenDTIIsGreaterThanMaxAllowed()
{
var loanApplication = new LoanApplication(
10_000, 5, 36, 1_500, 750);
var riskAnalyzeService = Substitute.For<IRiskAnalysisService>();
var calculator = Substitute.For<ILoanApplicationCalculator>();
calculator.GetDebtToIncome(loanApplication).Returns(40);
var processor = new LoanApplicationProcessor(
calculator,
riskAnalyzeService);
bool result = await processor.AnalyzeAsync(loanApplication);
result.Should().BeFalse();
calculator.Received(1).GetDebtToIncome(loanApplication);
riskAnalyzeService.DidNotReceive().AnalyzeAsync();
}
EL nuevo test, nos permite asegurar el comportamiento del sistema para las solicitudes cuyo cálculo del DTI sea superior al umbral máximo permitido de 35%, en cuyo caso, el sistema rechazará la solicitud de prestamo. Adicionalmente, nos cercioramos que solo se invoca el servicio calculator
y no se realiza ninguna llamada al servicio riskAnalyzeService
.
git commit -am "✅ test(LoanProcessor): Implement logic to deny a LoanRequest when the calculated DTI falls below 35%."
Continuando con los escenarios de rechazo, aseguremos que rechazamos las solicitudes que la API de análisis de riesgos rechaza. En este escenario, debemos garantizar que se invocan al menos una vez los servicios externos de calculator
y riskAnalyzeService
.
[Fact]
public async Task ShouldDenyLoanRequest_WhenRiskAPIRejectTheRequest()
{
var loanApplication = new LoanApplication(10_000, 5, 36, 1_500, 750);
var riskAnalyzeService = Substitute.For<IRiskAnalysisService>();
var calculator = Substitute.For<ILoanApplicationCalculator>();
calculator.GetDebtToIncome(loanApplication).Returns(30);
riskAnalyzeService.AnalyzeAsync().Returns(false);
var processor = new LoanApplicationProcessor(
calculator,
riskAnalyzeService);
bool result = await processor.AnalyzeAsync(loanApplication);
result.Should().BeFalse();
calculator.Received(1).GetDebtToIncome(loanApplication);
await riskAnalyzeService.Received(1).AnalyzeAsync();
}
El resultado de este unit test es exitoso, así que, confirmemos con un commit los cambios.
git commit -am "✅ test(LoanProcessor): Implement logic to deny a LoanRequest when the Risk Analysis API rejects the request."
Antes de continuar escribiendo más código, recordemos que en el ciclo de TDD una parte fundamental es el refactor. Por el momento, nuestra implementación es relativamente sencilla, por cual, no la refactorizaría. Sin embargo, los unit test también son parte de nuestro código y merecen el mismo mimo, cariño y dedicación. Observamos que tenemos una alta cantidad de código repetido, así que, hora de hacer limpieza.
public class LoanApplicationProcessorTests
{
private readonly ILoanApplicationCalculator _calculator;
private readonly LoanApplicationProcessor _processor;
private readonly IRiskAnalysisService _riskAnalyzeService;
public LoanApplicationProcessorTests()
{
_riskAnalyzeService = Substitute.For<IRiskAnalysisService>();
_calculator = Substitute.For<ILoanApplicationCalculator>();
_processor = new LoanApplicationProcessor(
_calculator,
_riskAnalyzeService);
}
[Fact]
public async Task ShouldApproveLoanRequest_WhenAllCriteriaAreMet()
{
var loanApplication = CreateDefaultLoanApplication();
_calculator.GetDebtToIncome(loanApplication).Returns(30);
_riskAnalyzeService.AnalyzeAsync().Returns(true);
bool result = await _processor.AnalyzeAsync(loanApplication);
result.Should().BeTrue();
_calculator.Received(1).GetDebtToIncome(loanApplication);
await _riskAnalyzeService.Received(1).AnalyzeAsync();
}
[Fact]
public async Task ShouldDenyLoanRequest_WhenCreditScoreIsLessThanMinimumRequired()
{
var loanApplication = CreateDefaultLoanApplication(700);
bool result = await _processor.AnalyzeAsync(loanApplication);
result.Should().BeFalse();
_calculator.DidNotReceive().GetDebtToIncome(Arg.Any<LoanApplication>());
await _riskAnalyzeService.DidNotReceive().AnalyzeAsync();
}
[Fact]
public async Task ShouldDenyLoanRequest_WhenDTIIsGreaterThanMaxAllowed()
{
var loanApplication = CreateDefaultLoanApplication();
_calculator.GetDebtToIncome(loanApplication).Returns(40);
bool result = await _processor.AnalyzeAsync(loanApplication);
result.Should().BeFalse();
_calculator.Received(1).GetDebtToIncome(loanApplication);
await _riskAnalyzeService.DidNotReceive().AnalyzeAsync();
}
[Fact]
public async Task ShouldDenyLoanRequest_WhenRiskAPIRejectTheRequest()
{
var loanApplication = CreateDefaultLoanApplication();
_calculator.GetDebtToIncome(loanApplication).Returns(30);
_riskAnalyzeService.AnalyzeAsync().Returns(false);
bool result = await _processor.AnalyzeAsync(loanApplication);
result.Should().BeFalse();
_calculator.Received(1).GetDebtToIncome(loanApplication);
await _riskAnalyzeService.Received(1).AnalyzeAsync();
}
private static LoanApplication CreateDefaultLoanApplication(int creditScore = 701) =>
new LoanApplication(10_000, 5, 36, 1_500, creditScore);
}
Veamos los cambios realizados a nuestra clase test:
- Centralizamos la configuración: Definimos un constructor para la clase
LoanApplicationProcessorTests
donde definimos una instancia para cada dependencia y para el SUT (LoanApplicationProcessor
) utilizando fields. Esto nos ayuda a evitar la duplicación de código en cada test. - Helper method para crear LoanApplication: Centralizamos la lógica para crear una nueva instancia de
LoanApplication
con valores predeterminados, facilitando la modificación de parámetros específicos para cada caso de prueba.
Acto seguido, nuevamente te invito a resguardar los cambios con un commit:
git commit -am "♻️ refactor(LoanProcessor): Improve setup and readability
Centralize setup in constructor.
Add a helper method for creating LoanApplication instances."
Por último y para dar por finalizada esta segunda parte de la serie, vamos a definir el unit test e implementación para nuestra clase LoanApplicationCalculator
, recordemos que para burlar al sistema definimos una interface ILoanApplicationCalculator
. Ahora debemos, asegurar el comportamiento esperado mediante un test y hacer que nuestra clase implemente la interface así que, ¡Manos al teclado!
// Implementar la interface ILoanApplicationCalculator en la clase LoanApplicationCalculator
public class LoanApplicationCalculator : ILoanApplicationCalculator
public decimal GetDebtToIncome(LoanApplication loanApplication) => throw new NotImplementedException();
// Agregar el unit test en la clase LoanApplicationCalculatorTests
[Theory]
[MemberData(nameof(GetLoanApplications))]
public void ShouldCalculateDebtToIncome_WhenGivenLoanApplication(LoanApplication loanApplication, decimal debtToIncomeExpected)
{
var calculator = new LoanApplicationCalculator();
decimal debtToIncome = calculator.GetDebtToIncome(loanApplication);
debtToIncome.Should().Be(debtToIncomeExpected);
}
public static IEnumerable<object[]> GetLoanApplications()
{
yield return new object[] { new LoanApplication(10_000, 5, 24, 1_900, 750), 23.09m };
yield return new object[] { new LoanApplication(10_000, 4, 24, 1_900, 750), 22.86m };
}
En este unit test hemos agregado una funcionalidad práctica de xUnit
que tiene por nombre MemberData
. Con el atributo MemberData
tenemos la capacidad de proporcionar los datos de prueba mediante programación.
git commit -am "🧪 test(LoanCalculator): Implement logic by calculate the debt to Income from a LoanApplication request."
Para finalizar, implementemos la lógica necesaria para aprobar el unit test.
public decimal GetDebtToIncome(LoanApplication loanApplication)
{
decimal monthlyInterestRate = GetMonthlyInterestRate(loanApplication.AnnualInterestRate);
decimal monthlyPayment = GetMonthlyPayment(loanApplication.AmountRequested, monthlyInterestRate,
loanApplication.TermInMonths);
return GetDebtToIncome(loanApplication.MonthlyIncome, monthlyPayment);
}
Finalicemos con un commit
git commit -am "✅ feat(LoanCalculator): Add logic by calculate the debt to income from a LoanApplication request."
Conclusion y próximos pasos
En esta segunda parte, hemos abordado la lógica base que determina si una solicitud de prestamo es autorizada o denegada por el sistema, hemos aprendido como las interfaces desacoplan los componentes del sistema, a simular el comportamiento del sistema mediante el uso de mocks y como asegurar que los mocks son utilizados como lo teniamos planteado.
En los siguientes artículos, dejaremos de lado por un momento los test y lógica de dominio, para profundizar en como aprovechar TDD para realizar las pruebas de la API desde un integration test que nos permita evaluar y comprobar como interactuan diferentes Layers de nuestro software.
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
- Introducción al TDD: Fundamentos y conceptos. Disponible en henksandoval.com
- Tutorial Práctico de TDD en Proyectos Reales con .NET: Parte I henksandoval.com
- NSubstitute. Disponible en NSubstitute