En los flujos tradicionales de desarrollo de software, las pruebas se escriben a posteriori, una vez que la lógica de negocio ya ha sido implementada. Esto engendra un vicio arquitectónico fundamental: el desarrollador amolda los tests a la estructura interna preexistente de clases, clausuras y estructuras de datos (Alto Acoplamiento a Detalles de Implementación). Como consecuencia directa, las interfaces públicas (Public APIs) se diseñan desde la perspectiva introspectiva del proveedor del servicio y no desde la ergonomía de su consumidor.
Test-Driven Development (TDD) invierte esta dinámica. Escribir una prueba que falla obliga al ingeniero a resolver de antemano los interrogantes esenciales de integración:
TDD actúa como una disciplina estricta de descomposición: las complejidades de negocio se dividen en una serie de especificaciones discretas y deterministas. Esto erradica el código especulativo (YAGNI — You Aren't Gonna Need It) y minimiza la carga cognitiva durante todo el proceso.
La formalización de TDD se apoya en tres leyes cardinales:
El respeto riguroso de estas reglas engendra un microciclo ininterrumpido: la redacción del test dura desde escasos segundos hasta uno o dos minutos, seguida inmediatamente por la edición mínima de código y la ejecución del runner.
El motor interno de TDD se representa mediante un flujo cerrado continuo: el ciclo Red-Green-Refactor.
Cada fase posee límites de responsabilidad estrictamente delimitados:
+-----------------------------------------------------------------------+
| TDD CYCLE |
+-----------------------------------------------------------------------+
| |
| +-------------------+ +--------------------+ |
| | 1. RED | ----> | 2. GREEN | |
| | Redacción del | | Código mínimo para | |
| | test que falla | | pasar la aserción | |
| +-------------------+ +--------------------+ |
| ^ | |
| | v |
| Nuevo | +--------------------+ |
| requi- | | 3. REFACTOR | |
| sito +---------------- | Eliminar olores | |
| | (tests en verde) | |
| +--------------------+ |
| |
+-----------------------------------------------------------------------+En la fase Red se redacta una especificación ejecutable sobre una funcionalidad que aún no existe. La regla rectora es: el test debe fallar exactamente por la razón esperada.
ReferenceError: calculateDiscount is not defined o un fallo de compilación en TypeScript, el límite del requerimiento queda formalizado.AssertionError: expected undefined to be 42), se constata la ausencia de la lógica requerida.El objetivo de la fase Green consiste en salir del estado de fallo con la máxima celeridad. Se admiten tácticas pragmáticas:
Únicamente cuando la totalidad de los tests se encuentran en verde, el ingeniero adquiere la potestad de modificar el código fuente sin temor a regresiones. La fase Refactor persigue:
Implementaremos un servicio de validación de permisos en tokens (ScopeEvaluator) siguiendo la disciplina rigurosa de TDD con Vitest.
Formulamos la especificación inicial: el evaluador debe rechazar cadenas vacías arrojando una excepción personalizada de seguridad.
import { describe, it, expect } from 'vitest';
import { ScopeEvaluator, SecurityTokenError } from './scope-evaluator';
describe('ScopeEvaluator', () => {
it('arroja SecurityTokenError al recibir una cadena vacía', () => {
const evaluator = new ScopeEvaluator();
expect(() => evaluator.evaluateToken('', ['read:users'])).toThrow(
SecurityTokenError
);
});
});Al ejecutar Vitest se genera un error de compilación de TypeScript: el módulo ./scope-evaluator no existe. La fase RED concluye con éxito demostrando la ausencia del código.
Escribimos la cantidad mínima de código requerida para teñir la suite de verde.
export class SecurityTokenError extends Error {
constructor(message: string) {
super(message);
this.name = 'SecurityTokenError';
}
}
export class ScopeEvaluator {
evaluateToken(token: string, requiredScopes: readonly string[]): boolean {
if (!token) {
throw new SecurityTokenError('Token cannot be empty');
}
return false;
}
}El test se ejecuta y aprueba satisfactoriamente (PASS).
Ampliamos el contrato: los tokens válidos deben portar el prefijo Bearer y una carga útil base64url que declare los permisos asignados.
it('retorna true cuando el token contiene todos los permisos solicitados', () => {
const evaluator = new ScopeEvaluator();
// Carga útil codificada: {"sub":"usr_123","scopes":["read:users","write:orders"]}
const payload = Buffer.from(
JSON.stringify({
sub: 'usr_123',
scopes: ['read:users', 'write:orders'],
})
).toString('base64url');
const rawToken = `Bearer ${payload}`;
const hasAccess = evaluator.evaluateToken(rawToken, ['read:users']);
expect(hasAccess).toBe(true);
});
it('retorna false cuando al token le falta uno o más permisos requeridos', () => {
const evaluator = new ScopeEvaluator();
const payload = Buffer.from(
JSON.stringify({
sub: 'usr_123',
scopes: ['read:users'],
})
).toString('base64url');
const rawToken = `Bearer ${payload}`;
const hasAccess = evaluator.evaluateToken(rawToken, ['read:users', 'admin:all']);
expect(hasAccess).toBe(false);
});La suite falla (FAIL) puesto que evaluateToken retorna incondicionalmente false.
Incorporamos la decodificación de la carga útil base64url y la verificación de conjuntos.
interface TokenPayload {
readonly sub: string;
readonly scopes: readonly string[];
}
export class ScopeEvaluator {
evaluateToken(token: string, requiredScopes: readonly string[]): boolean {
if (!token || !token.trim()) {
throw new SecurityTokenError('Token cannot be empty');
}
if (!token.startsWith('Bearer ')) {
throw new SecurityTokenError('Invalid authorization scheme');
}
const base64Payload = token.slice(7);
let parsed: TokenPayload;
try {
const decodedJson = Buffer.from(base64Payload, 'base64url').toString('utf-8');
parsed = JSON.parse(decodedJson) as TokenPayload;
} catch {
throw new SecurityTokenError('Malformed token payload');
}
if (!Array.isArray(parsed.scopes)) {
return false;
}
const tokenScopeSet = new Set(parsed.scopes);
return requiredScopes.every((scope) => tokenScopeSet.has(scope));
}
}Los 3 tests pasan en verde (PASS). Proseguimos a la fase de refactorización.
La clase ScopeEvaluator vulnera el Principio de Responsabilidad Única (SRP): valida cabeceras, decodifica base64, deserializa JSON y coteja permisos. Extraemos un TokenParser especializado sin alterar el comportamiento observable ni romper ningún test.
export class SecurityTokenError extends Error {
constructor(message: string) {
super(message);
this.name = 'SecurityTokenError';
}
}
export interface DecodedToken {
readonly subject: string;
readonly scopes: ReadonlySet<string>;
}
export class TokenParser {
private static readonly BEARER_PREFIX = 'Bearer ';
public static parse(rawToken: string): DecodedToken {
if (!rawToken || !rawToken.trim()) {
throw new SecurityTokenError('Token cannot be empty');
}
if (!rawToken.startsWith(this.BEARER_PREFIX)) {
throw new SecurityTokenError('Invalid authorization scheme');
}
const base64Payload = rawToken.slice(this.BEARER_PREFIX.length);
try {
const json = Buffer.from(base64Payload, 'base64url').toString('utf-8');
const payload = JSON.parse(json) as { sub?: unknown; scopes?: unknown };
if (typeof payload.sub !== 'string' || !Array.isArray(payload.scopes)) {
throw new Error('Invalid token structure');
}
return {
subject: payload.sub,
scopes: new Set(payload.scopes.filter((s): s is string => typeof s === 'string')),
};
} catch (error) {
if (error instanceof SecurityTokenError) throw error;
throw new SecurityTokenError('Malformed token payload');
}
}
}
export class ScopeEvaluator {
public evaluateToken(token: string, requiredScopes: readonly string[]): boolean {
const decoded = TokenParser.parse(token);
return this.hasRequiredScopes(decoded.scopes, requiredScopes);
}
private hasRequiredScopes(
grantedScopes: ReadonlySet<string>,
requiredScopes: readonly string[]
): boolean {
return requiredScopes.every((scope) => grantedScopes.has(scope));
}
}Vitest constata que el 100% de las pruebas continúan en verde, mientras que la estructura interna ha ganado modularidad, tipado estricto y desacoplamiento.
Escribir aserciones que comprueban métodos privados o inspeccionan propiedades internas de las instancias (por ejemplo, evaluator['cachedScopes']). Dichas pruebas se quiebran ante cualquier refactorización interna aunque el comportamiento público permanezca impoluto. Se debe testear exclusivamente el contrato público: entradas y salidas.
Pasar de inmediato al siguiente requerimiento en cuanto la prueba se vuelve verde sin auditar olores de código (Code Smells). La deuda técnica en TDD se acumula aquí: sin refactorización continua, el código degenera en una maraña de condicionales ad-hoc concebidos únicamente para complacer al runner.
Intentar escribir una prueba titánica que cubra un flujo con múltiples bifurcaciones a la vez. Cuando el test falla, resulta arduo aislar la causa raíz. Los pasos deben mantenerse atómicos: una prueba describe un único incremento conceptual de negocio.
Construir arquitecturas complejas o factorías abstractas en plena fase Green. Durante esta fase se debe implementar la solución más directa e ingenua posible. Las abstracciones y patrones se introducen en la fase Refactor cuando la triangulación demuestra su necesidad real.
Elegir un incremento atómico de comportamiento. Redactar una prueba unitaria contra la API pública esperada. Ejecutar el runner y verificar el fallo por la razón prevista.
Escribir el código estrictamente indispensable para que el test pase. Se permite el uso provisional de constantes directas o condicionales sencillos.
Ejecutar la suite completa de pruebas para constatar que tanto el nuevo test como todas las pruebas preexistentes permanecen en verde.
Revisar el código en busca de duplicidades y adherencia a SOLID. Mejorar la estructura interna asegurando que los tests permanezcan 100% en verde.
Registrar el incremento verificado en el control de versiones (Git) con un mensaje claro que documente el cambio contractual.