Test-Driven Development (TDD)
El ciclo Red-Green-Refactor, por qué TDD también es feedback de diseño y qué NO demuestran los unit tests.
TDD es una práctica en la que los tests guían la implementación del comportamiento a través de un ciclo de feedback continuo: Red, Green y Refactor.
flowchart LR R["RED<br/>Test que falla"] G["GREEN<br/>Implementación mínima"] F["REFACTOR<br/>Mejorar el diseño"] R --> G --> F --> R classDef red fill:#fee2e2,stroke:#ef4444,color:#991b1b classDef green fill:#dcfce7,stroke:#22c55e,color:#166534 classDef refactor fill:#fef3c7,stroke:#f59e0b,color:#92400e class R red class G green class F refactor
Red
Escribimos un test que expresa el comportamiento deseado. Como todavía no existe la implementación, falla —y ese failure es esperado—:
it('waives the deposit for gold tier guests', () => {
const deposit = calculateDeposit(4, { loyaltyTier: 'GOLD' });
expect(deposit).toEqual(Money.zero());
});
Green
Implementamos lo mínimo necesario para que pase. Literalmente lo mínimo:
function calculateDeposit(partySize: number, guest: Guest): Money {
return Money.zero();
}
Sí, está mal para cualquier otro caso. Ese es justamente el punto: si esta implementación absurda pasa toda la suite, la suite todavía no dice nada sobre el caso general. El paso siguiente es escribir el test que la rompe.
it('charges $20 per guest for regular tiers', () => {
const deposit = calculateDeposit(4, { loyaltyTier: 'STANDARD' });
expect(deposit).toEqual(Money.of(80));
});
Recién ahora la generalización está justificada por un test que la exige:
function calculateDeposit(partySize: number, guest: Guest): Money {
if (guest.loyaltyTier === 'GOLD') return Money.zero();
return Money.of(20).multiply(partySize);
}
Refactor
Con el test en verde, mejoramos diseño e implementación sin cambiar el comportamiento observable: extraer métodos, eliminar duplicación, mejorar naming, introducir una abstracción. Los tests funcionan como red de seguridad durante ese proceso.
TDD no es solo “escribir tests primero”
Esa frase describe el proceso, pero no su impacto más importante: TDD también da feedback sobre el diseño. Si para testear ReservationService hace falta construir seis dependencias distintas, configurar el test se vuelve tan trabajoso que la dificultad deja de ser un problema de testing y pasa a ser una señal de diseño —demasiadas responsabilidades, coupling excesivo, boundaries mal definidos—. Es mucho más fácil testear una clase que separa domain rules, calculations y state transitions, que una que además mezcla acceso a base de datos, HTTP, el reloj del sistema y variables de entorno. TDD hace evidente ese problema temprano, porque el test es el primer consumidor del API.
Ese feedback es el mismo que dan los principios de diseño desde el otro lado: un test difícil de armar suele estar señalando una violación de Single Responsibility o una dependencia concreta que debería haber sido una abstracción (Inversión de Dependencias). La diferencia es que el test te lo dice hoy, y con un ejemplo concreto.
Qué NO deberían intentar demostrar los Unit Tests
Tienen límites claros: no sirven para demostrar que los mappings contra la base de datos son correctos, que un servicio externo funciona, que el deployment está bien configurado, o que el user journey completo funciona de punta a punta. Un unit test de calculateCancellationFee demuestra que la matemática es correcta; no demuestra que la seña efectivamente se reintegre a través del proveedor de pagos real, ni que el resto del sistema reaccione bien a ese cambio de estado. Esas preguntas requieren otros niveles de testing, que es justamente de lo que hablan los próximos artículos de este tema.
Errores comunes
Además de acoplarse a implementation details y de abusar de los mocks, dos problemas aparecen seguido: testear solo el happy path —dejando afuera boundaries, inputs inválidos y failure states relevantes para el dominio— y juntar demasiadas assertions en un mismo test, lo que hace más difícil entender qué comportamiento puntual protege. shouldRejectExpiredCoupon es un nombre mucho más útil que shouldValidateCouponAndCalculateDiscountAndUpdateOrderAndNotifyUser: el segundo probablemente está verificando demasiado en un solo test. Perseguir un número de coverage tiene el mismo problema de fondo; la pregunta que importa es otra: ¿qué comportamientos importantes todavía no están protegidos por tests significativos?
Un mental model práctico
Antes de escribir un unit test, sirve recorrer esta secuencia: qué comportamiento quiero proteger, qué inputs y estado inicial importan, cuál es el resultado observable esperado, qué dependencias participan y cuáles necesito aislar, qué boundaries y failure cases existen, y si el test que estoy por escribir es determinista e independiente del resto de la suite. Ese razonamiento debería terminar en algo simple: Arrange, Act, Assert. La complejidad tiene que estar en entender el comportamiento, no en ejecutar el test.
Con esta base de testing unitario, subimos un nivel: qué pasa cuando la unidad deja de estar sola y empieza a interactuar con otros componentes, en testing de integración.
