¿Qué es un Unit Test?
Qué significa aislar una unidad, qué debería cubrir un unit test y cuándo el test queda demasiado acoplado a la implementación.
El testing unitario es una de las técnicas fundamentales de toda estrategia de testing de software: da feedback rápido sobre el comportamiento de piezas chicas de código y detecta defectos cerca de donde se producen. Pero escribir unit tests no consiste simplemente en invocar un método y agregar assertions. La calidad de un unit test depende de qué tan bien aísla el comportamiento que quiere verificar, qué tan claramente expresa su intención y qué tan estable permanece a medida que el código evoluciona.
Un unit test verifica el comportamiento de una unidad relativamente pequeña y aislada del software. Dependiendo del lenguaje y de la arquitectura, esa unidad puede ser una función, un método, una clase o un pequeño componente de dominio.
En ReservaResto, una unidad candidata es el cálculo de la penalidad por cancelación tardía:
function calculateCancellationFee(
deposit: Money,
hoursBeforeReservation: number
): Money {
if (hoursBeforeReservation >= 24) return Money.zero();
return deposit.multiply(0.5);
}
Y el test correspondiente:
Given: deposit = $2000, hoursBeforeReservation = 3
Then: fee = $1000
Given: deposit = $2000, hoursBeforeReservation = 30
Then: fee = $0
La característica importante no es simplemente que el test sea chico. Un buen unit test debería ofrecer:
- feedback rápido;
- resultados deterministas;
- aislamiento de dependencias irrelevantes;
- una causa de failure fácil de identificar;
- una descripción clara del comportamiento esperado.
Aislamiento
ReservationService en ReservaResto depende de PricingService, TableAvailabilityService y PaymentService. Un unit test de ReservationService no debería necesitar una base de datos real, una API de pagos real, ni ningún otro servicio remoto: esas dependencias se reemplazan por test doubles cuando corresponde.
ReservationService ← la unidad bajo test
├── PricingService ← reemplazado
├── TableAvailabilityService ← reemplazado
└── PaymentService ← reemplazado
Con qué se reemplaza cada una no es una propiedad de la dependencia, sino de lo que ese test en particular quiere verificar: la misma PaymentService puede reemplazarse de tres formas distintas en tres tests distintos. Eso es lo que cubre el artículo sobre test doubles.
Qué deberían cubrir
Especialmente reglas de negocio, casos límite, condiciones de frontera, lógica de ramificación, invariantes y manejo de errores. No deberían usarse exclusivamente para perseguir un porcentaje arbitrario de code coverage:
100% coverage ≠ 100% correctness
Code coverage indica qué código fue ejecutado, no si fue correctamente verificado. Un test puede ejecutar calculateCancellationFee y no verificar nada relevante sobre su resultado.
Cuándo deja de ser útil
Un test puede acoplarse demasiado a la implementación:
assert que se llamó a PaymentService.charge() antes que a TableService.block()
assert sobre un detalle interno de implementación
assert sobre la estructura exacta de un objeto interno
Esto genera tests que fallan cuando cambia la implementación, aunque el comportamiento observable siga siendo correcto. La idea que hay que sostener:
Testear comportamiento, no implementación accidental.
En el próximo artículo vemos F.I.R.S.T., la regla mnemotécnica más usada para chequear si un unit test está bien pensado.
