ConstructiCat Logo
CodeBust.
Browse section ▾

Código difícil de probar.

Código de producción cuyo diseño (acoplamiento fuerte, dependencias ocultas, estado global, E-S no determinista o interfaces solo asíncronas) obliga a los tests a contorsiones incómodas, o hace imposible ejercitar una unidad en aislamiento.

##Signs and Symptoms

Este olor vive en el código de producción, pero lo descubres al escribir tests. La señal reveladora es que un test unitario no se puede escribir con limpieza: el test tiene que pelear con el diseño para poner el código bajo prueba en un estado conocido y observar el resultado.

Vigila estos síntomas:

  • Bloques de preparación gigantes. Debes construir una maraña de colaboradores solo para instanciar la clase bajo prueba ("no puedo probar OrderService sin cablear también una BD, una pasarela y un cargador de configuración").
  • Sin costura para inyectar un doble. El código llama a new ConcreteThing(), accede a un singleton estático (Database.getInstance()) o golpea la red/el sistema de archivos/el reloj directamente, así que no hay dónde sustituir un stub o un fake.
  • No determinismo incrustado. La lógica depende de Date.now(), Math.random(), process.env o de la temporización del reloj que el test no puede controlar, produciendo resultados inestables o irrepetibles.
  • Sin punto de observación / sin punto de control. El resultado que quieres afirmar está enterrado en estado privado o en un efecto secundario, así que los tests hurgan mediante reflexión, conversiones o getters solo para pruebas.
  • Interfaz solo asíncrona. La única forma de impulsar el código es arrancar un temporizador/hilo/cola y luego sleep() o sondear, porque la finalización nunca es observable directamente.
  • Cambias el código de producción solo para probarlo. Aflojar private a public, añadir ganchos de prueba if (testMode) { ... } o crear subclases solo para alcanzar las entrañas.
// Difícil de probar: dependencias ocultas + cableadas, estado global, no determinismo
class OrderService {
  placeOrder(cart) {
    const db = Database.getInstance();            // singleton global (sin costura)
    const gateway = new StripeGateway(API_KEY);   // dep concreta cableada -> red real
    const id = Math.random().toString(36).slice(2); // no determinista
    const charge = gateway.charge(cart.total);    // llamada HTTP real dentro de la unidad
    db.save({ id, at: Date.now(), charge });       // reloj no inyectado
    return id;
  }
}
// Para hacer un "test unitario" de esto debes golpear Stripe, parchear Date/Math globalmente
// e inspeccionar un singleton compartido — es decir, Pruebas indirectas a través de interfaces incómodas.

##Reasons for the Problem

Por qué ocurre

  • Test-Last sobre código heredado. Meszaros nombra la causa raíz como la falta de diseño para la testabilidad. La testabilidad surge de forma natural del TDD, pero cuando los tests se escriben "al final", nada presionó al diseño para que expusiera puntos de control y de observación, así que hay que añadirlos a posteriori.
  • Acoplamiento fuerte y dependencias cableadas. Instanciar colaboradores concretos con new (o sacarlos de singletons estáticos) significa que no puedes sustituirlos por dobles de prueba: el test ejercita la unidad y todo lo que toca.
  • Estado global/compartido y entradas ocultas. Los singletons, las variables estáticas, el tiempo ambiente, la aleatoriedad y las lecturas del entorno son entradas que el test nunca declaró y no puede fijar, así que el comportamiento es implícito e incontrolable.
  • Asincronía y efectos secundarios en el núcleo. Cuando la lógica de negocio se entrelaza con hilos, colas, E-S o la interfaz de usuario, no hay ningún valor puro que afirmar.

Por qué hace daño

  • Fiabilidad. Los tests obligados a usar E-S reales, sleeps, singletons compartidos o relojes globales se vuelven lentos, inestables y dependientes del orden: el camino clásico hacia un Test errático/frágil.
  • Mantenibilidad. Las configuraciones enormes y los tests que hurgan en las entrañas acoplan la suite de tests a los detalles de implementación, de modo que refactorizaciones inofensivas rompen tests (Test frágil / Fixture frágil).
  • Falsa confianza. Las vías de código más difíciles e importantes se prueban solo a través de interfaces toscas e indirectas, o se saltan por completo. Los números de cobertura parecen bien mientras la lógica arriesgada apenas se ejercita.
  • Legibilidad. Un test dominado por el andamiaje oscurece el único comportamiento que se supone que debe especificar, así que no documenta nada.

En el Catálogo abierto de olores de test, el Código difícil de probar es la contraparte, en el lado de producción, de olores de test como las Pruebas indirectas y el Test frágil: el mal diseño es la causa, los tests incómodos son el síntoma.

##Treatment

Arregla el diseño, no el test. El objetivo es dar a cada unidad un punto de control (una forma de ponerla en un estado conocido) y un punto de observación (una forma de leer el resultado).

  1. Introduce costuras mediante inyección de dependencias. Pasa los colaboradores (inyección por constructor) en lugar de construirlos o buscarlos. Inyecta también las entradas ambiente —reloj, generador de id/uuid, fuente de aleatoriedad— para que se conviertan en parámetros controlables.
  2. Depende de abstracciones. Programa contra una interfaz y sustituye un stub/fake/mock en los tests. Esto elimina directamente la Dependencia cableada de Designite y recorta la Dependencia excesiva.
  3. Aplica el patrón Humble Object. Empuja las partes genuinamente difíciles de probar (UI, pegamento asíncrono, E-S en crudo) a un adaptador fino sin lógica, y mueve la lógica de decisión a un objeto plano y síncrono que puedas probar directamente.
  4. Extrae funciones puras. Separa el cálculo de los efectos secundarios; afirma sobre los valores devueltos, realiza la E-S solo en el límite.
  5. Haz observable lo asíncrono. Devuelve una promesa/futuro o expón una señal de finalización, e inyecta el planificador para que los tests usen temporizadores falsos en vez de sleep.
  6. Para el código heredado que aún no puedes rediseñar, usa una Subclase específica de prueba o una costura de alcance reducido (técnicas de Feathers) para poner el código bajo prueba, y luego refactoriza hacia la inyección, en lugar de dejar ganchos if (testMode) permanentes en producción.
  7. Evita el estado global/estático. Reemplaza los singletons por dependencias pasadas explícitamente para que los tests no compartan ni reinicien estado oculto.
// DESPUÉS: dependencias, reloj e id inyectados -> probable en aislamiento
class OrderService {
  constructor(db, gateway, clock = () => Date.now(), newId = uuid) {
    this.db = db; this.gateway = gateway; this.clock = clock; this.newId = newId;
  }
  placeOrder(cart) {
    const id = this.newId();
    const charge = this.gateway.charge(cart.total); // una abstracción PaymentGateway
    this.db.save({ id, at: this.clock(), charge });
    return id;
  }
}

// Test: determinista, sin red, sin parcheo global, rápido.
const svc = new OrderService(fakeDb, fakeGateway, () => 1000, () => 'id-1');
expect(svc.placeOrder({ total: 50 })).toBe('id-1');
expect(fakeGateway.charge).toHaveBeenCalledWith(50);
expect(fakeDb.saved[0]).toEqual({ id: 'id-1', at: 1000, charge: fakeGateway.result });

##Detected by