Testing profundo: unitarias, mocks, cobertura y regresión
Ya sabés escribir pruebas a partir de los criterios de aceptación. Ahora el sistema crece: hay base de datos, hay correos, hay otros servicios. Esta guía enseña a probar cada pieza por separado sin depender de las demás, a leer un informe de cobertura sin dejarse engañar, a blindar un bug corregido para que no vuelva y, al final, a ver cómo cambia todo esto si el sistema se parte en microservicios.
Bienvenido
Camila, la Product Owner de EventoUAB, pidió algo nuevo para el Sprint 3: "Cuando una reserva queda confirmada, el club tiene que recibir un correo." Valentina lo implementa y se encuentra con un problema práctico: para probar reservar ahora necesitaría una base de datos y un servidor de correo funcionando. Cada corrida de pruebas mandaría correos de verdad.
Mateo le propone otro camino: "No pruebes todo junto. Probá la regla sola, y reemplazá lo que no te interesa por un sustituto de mentira." Ese es el hilo de la guía: aislar lo que se prueba para que las pruebas sean rápidas, repetibles y fáciles de diagnosticar.
Al finalizar esta guía sabrás ubicar cada prueba en la pirámide (unitaria, de integración, extremo a extremo), elegir entre stub, fake y espía según lo que quieras comprobar, interpretar un informe de cobertura sabiendo qué NO te dice, convertir un bug en una prueba de regresión, y explicar cómo cambia la estrategia de pruebas al pasar a microservicios.
Encontrarás actividades cortas y autocorregibles —selección múltiple, clasificación, emparejamiento y completar espacios— repartidas a lo largo de la guía, y una sección de ejercicios de razonamiento donde se te da un escenario y debés decidir antes de comprobar.
🔺 Pirámide
Muchas pruebas rápidas y chicas, pocas lentas y grandes.
🎭 Dobles de prueba
Sustitutos que reemplazan la base de datos o el correo.
📊 Cobertura
Qué código ejecutaron las pruebas, y qué no comprobaron.
La pirámide de pruebas
No todas las pruebas son iguales. Se distinguen por cuánto del sistema recorren, y de eso dependen su velocidad, su costo y qué tan fácil es saber qué se rompió.
| Nivel | Qué recorre | Ejemplo en EventoUAB | Velocidad |
|---|---|---|---|
| Unitaria | Una función o clase sola; lo demás se reemplaza. | ReservasService.reservar con un repositorio en memoria. | Milisegundos |
| De integración | Varias piezas reales juntas. | El servicio con el repositorio SQLite de verdad. | Décimas de segundo a segundos |
| Extremo a extremo | La aplicación entera, como la usaría una persona. | Abrir el navegador, reservar un espacio y ver la confirmación. | Segundos a minutos |
Conviene tener muchas pruebas unitarias en la base (rápidas, baratas, señalan la línea exacta que falló), algunas de integración en el medio y pocas de extremo a extremo arriba (lentas, frágiles, pero las únicas que comprueban que todo encaja). Invertir la pirámide —casi todo extremo a extremo— produce una suite que tarda mucho, falla por motivos ajenos al código y nadie quiere correr.
¿En qué nivel de la pirámide está cada prueba de EventoUAB?
reservar con un repositorio en memoria.
Stubs, fakes y espías
Para que una prueba sea unitaria, lo que rodea al código se reemplaza por un doble de prueba, como los dobles de las películas. Este es el servicio de EventoUAB con la nueva regla de Camila: depende de un repositorio y de un notificador, ambos recibidos de afuera (la inyección de dependencias de la guía anterior).
export class ReservasService {
constructor(repo, notificador) {
this.repo = repo;
this.notificador = notificador;
}
reservar(nueva) {
if (nueva.fin <= nueva.inicio) {
throw new Error("La hora de fin debe ser posterior al inicio");
}
const ocupado = this.repo
.delEspacio(nueva.espacioId)
.some((r) => r.inicio < nueva.fin && r.fin > nueva.inicio);
if (ocupado) return false;
this.repo.guardar(nueva);
this.notificador.enviarConfirmacion(nueva.club);
return true;
}
}
Hay tres tipos de doble, y cada uno responde a una pregunta distinta:
| Doble | Qué hace | Úsalo cuando querés comprobar... |
|---|---|---|
| Stub | Devuelve respuestas fijas, enlatadas. | Cómo reacciona tu código ante una situación concreta (espacio ocupado, error de la base). |
| Fake | Una implementación real pero simplificada (una lista en memoria en vez de SQLite). | Un comportamiento que depende del estado: guardar varias cosas y consultarlas. |
| Espía (mock) | Anota cómo lo llamaron para preguntarlo después. | Que el código hizo algo hacia afuera: enviar un correo, llamar a otro servicio. |
import { test } from "node:test";
import assert from "node:assert/strict";
import { ReservasService } from "./reservas-service.js";
// Fake: una implementación real pero simple, en memoria
class ReservasEnMemoria {
constructor() { this.todas = []; }
delEspacio(id) { return this.todas.filter((r) => r.espacioId === id); }
guardar(r) { this.todas.push(r); }
}
// Espía: no hace nada útil, solo anota cómo lo llamaron
class NotificadorEspia {
constructor() { this.llamadas = []; }
enviarConfirmacion(club) { this.llamadas.push(club); }
}
test("stub: con el espacio ocupado, rechaza", () => {
const repoStub = {
delEspacio: () => [{ espacioId: 1, inicio: 10, fin: 12 }], // respuesta enlatada
guardar: () => { throw new Error("no debería guardar"); },
};
const servicio = new ReservasService(repoStub, new NotificadorEspia());
assert.equal(servicio.reservar({ espacioId: 1, club: "Robótica", inicio: 11, fin: 13 }), false);
});
test("fake: dos reservas contiguas conviven", () => {
const servicio = new ReservasService(new ReservasEnMemoria(), new NotificadorEspia());
assert.equal(servicio.reservar({ espacioId: 1, club: "A", inicio: 10, fin: 12 }), true);
assert.equal(servicio.reservar({ espacioId: 1, club: "B", inicio: 12, fin: 14 }), true);
});
test("espía: una reserva exitosa avisa una sola vez al club", () => {
const notificador = new NotificadorEspia();
const servicio = new ReservasService(new ReservasEnMemoria(), notificador);
servicio.reservar({ espacioId: 1, club: "Robótica", inicio: 10, fin: 12 });
assert.deepEqual(notificador.llamadas, ["Robótica"]);
});
test("espía: una reserva rechazada no avisa a nadie", () => {
const notificador = new NotificadorEspia();
const servicio = new ReservasService(new ReservasEnMemoria(), notificador);
servicio.reservar({ espacioId: 1, club: "A", inicio: 10, fin: 12 });
servicio.reservar({ espacioId: 1, club: "B", inicio: 11, fin: 13 });
assert.deepEqual(notificador.llamadas, ["A"]);
});
Corren juntas en milisegundos y dan el mismo resultado en cualquier computadora. Fijate también en el stub: su guardar lanza un error. Si el servicio guardara una reserva que debía rechazar, la prueba fallaría sola.
Un espía comprueba qué hizo el código hacia afuera. Úsalo para efectos reales (el correo salió, y una sola vez). No lo uses para verificar el orden de llamadas internas ni cuántas veces se consultó el repositorio: esas pruebas se rompen cada vez que reorganizás el código por dentro aunque el comportamiento siga siendo correcto. Preferí comprobar el resultado sobre el procedimiento.
Une cada doble de prueba con lo que lo caracteriza.
Elegí el doble adecuado para cada necesidad de prueba.
Para probar cómo reacciona el servicio cuando el repositorio ya devuelve un horario ocupado, uso un ; para comprobar que el club recibió exactamente un correo, uso un ; y para guardar varias reservas seguidas y consultarlas después, uso un .
Una prueba verifica que reservar llamó a repo.delEspacio exactamente dos veces y en cierto orden. Mateo reorganiza el código por dentro, el comportamiento sigue igual y la prueba falla. ¿Qué pasó?
Cobertura: qué dice y qué no dice
La cobertura es el porcentaje de código que las pruebas ejecutaron al correr. Node la calcula sin instalar nada: node --test --experimental-test-coverage. Es una herramienta útil, pero se interpreta mal con mucha facilidad.
Esta es una prueba de Valentina para "tener cobertura". Ejecuta reservar pero no comprueba absolutamente nada:
test("camino feliz, pero sin comprobar nada", () => {
const s = new ReservasService(
{ delEspacio: () => [], guardar() {} },
{ enviarConfirmacion() {} }
);
s.reservar({ espacioId: 1, club: "A", inicio: 10, fin: 12 }); // ← ni un assert
});
Solo la prueba sin asserts:
file | line % | branch % | funcs % | uncovered lines
reservas-service.js | 89.47 | 60.00 | 66.67 | 9-10
Las 4 pruebas con asserts de la sección anterior:
reservas-service.js | 89.47 | 85.71 | 100.00 | 9-10
Esas 4 + la prueba de "fin anterior al inicio":
reservas-service.js | 100.00 | 100.00 | 100.00 |
Mirá la primera línea: una prueba que no comprueba nada alcanza el mismo 89,47 % de líneas que las cuatro pruebas bien escritas. El informe no distingue entre ejecutar el código y verificarlo. La columna "uncovered lines" sí es valiosa: señala las líneas 9-10, el error de "fin anterior al inicio", que ninguna prueba había tocado todavía.
Para ver la diferencia real, repetimos el experimento de la guía anterior: cambiar r.fin > nueva.inicio por r.fin >= nueva.inicio en la regla de solapamiento, a propósito.
| Qué se corre contra el código con el error | Resultado |
|---|---|
| La prueba sin asserts | ✔ pasa (no detecta nada) |
| Las 5 pruebas con asserts | ✖ falla "fake: dos reservas contiguas conviven" (4 pasan, 1 falla) |
Una cobertura baja es información confiable: ese código no está probado. Una cobertura alta no garantiza nada: solo dice que el código se ejecutó, no que alguien comprobara su resultado. Úsala para encontrar lo que falta, no como meta. Un equipo que persigue "100 %" por sí mismo termina escribiendo pruebas sin asserts, justo como la de arriba.
El informe de cobertura marca 100 % y aun así se cuela un error de comparación. ¿Cuál es la explicación más probable?
Completá lo que le pasó a la prueba sin asserts de Valentina.
Cuando mido la cobertura de una prueba que ejecuta reservar sin comprobar nada, las líneas ejecutadas figuran como
; si alguien cambia > por >=, esa prueba
; la cobertura mide
.
Pruebas de regresión
Una regresión es algo que antes funcionaba y dejó de funcionar después de un cambio. Es el bug más frustrante, porque ya lo habías resuelto. Las pruebas automáticas existen, en buena parte, para atraparlas.
Escenario: en un apuro, alguien "simplifica" la regla de solapamiento y deja r.fin >= nueva.inicio. Una reserva que empieza justo cuando termina otra pasa a rechazarse. Si corrés la suite, el error aparece solo:
✔ stub: con el espacio ocupado, rechaza
✖ fake: dos reservas contiguas conviven
✔ espía: una reserva exitosa avisa una sola vez al club
✔ espía: una reserva rechazada no avisa a nadie
✔ inválido: fin anterior al inicio lanza error
ℹ tests 5 ℹ pass 4 ℹ fail 1
Otro cambio del mismo estilo —avisar al club también cuando la reserva se rechaza— hace fallar a "espía: una reserva rechazada no avisa a nadie", y solo a esa. Cada error cae en la prueba que describe justo esa regla, y eso es lo que hace útil una suite de pruebas pequeñas y específicas.
Si un error llega a producción, no alcanza con corregirlo. Hay un orden que evita que vuelva: 1) escribí una prueba que reproduzca el bug y comprobá que falla; 2) corregí el código; 3) verificá que esa prueba pasa y que toda la suite sigue en verde; 4) dejá la prueba en el repositorio para siempre. El paso 1 importa: una prueba que nunca se vio fallar no prueba que detecte el bug.
La DoD de la guía anterior ya exigía "las pruebas pasan en main después de integrar". Es exactamente esta defensa: correr la suite completa en cada integración convierte cada regresión en un aviso inmediato, en vez de un reclamo del usuario semanas después.
Reconstruí el ciclo para corregir un bug sin que vuelva a aparecer.
Primero escribo una prueba que reproduce el bug y compruebo que ; después ; luego confirmo que ; y finalmente .
Un usuario reporta un bug. Valentina lo corrige directamente y lo prueba a mano una vez. ¿Qué le falta para que sea una corrección con protección contra regresión?
Panorama: microservicios
Hasta acá EventoUAB es una sola aplicación. Muchas empresas, en cambio, parten el sistema en microservicios: servicios pequeños e independientes (reservas, notificaciones, usuarios...), cada uno con su propio código, su propia base de datos y su propio despliegue, que se hablan entre sí por red. No es un tema para dominar en una guía; sí para entender por qué cambia la forma de probar.
| Una sola aplicación (monolito) | Microservicios | |
|---|---|---|
| Cómo se hablan las piezas | Llamadas a funciones dentro del mismo programa. | Peticiones por red (HTTP, colas de mensajes). |
| Pruebas unitarias | Igual que hasta ahora. | Igual: cada servicio se prueba por dentro con dobles. |
| Pruebas de integración | Piezas reales en el mismo proceso. | Hay que acordar el contrato entre servicios: qué pide uno y qué responde el otro. |
| Extremo a extremo | Levantar una aplicación. | Levantar varios servicios juntos: más lento, más frágil, más caro. |
Si el servicio de reservas llama al de notificaciones, ambos equipos deben ponerse de acuerdo en el formato del mensaje. En una prueba de contrato el consumidor declara qué espera recibir y el proveedor verifica, en su propia suite, que realmente lo cumple. Así detectan un cambio incompatible sin levantar ambos servicios juntos. Es la versión en red del "doble" que ya conocés: un acuerdo escrito que reemplaza al servicio real.
Casi seguro que no, todavía. Los microservicios ayudan cuando hay varios equipos que necesitan desplegar sin esperarse, o partes del sistema con carga muy distinta. Con un equipo pequeño agregan problemas nuevos (red, versiones, contratos, más pruebas lentas) sin resolver un dolor que el equipo tenga. Es el mismo criterio que con Repository: se incorpora donde la evidencia muestra el problema, no por anticipado.
Para cada situación, ¿partir el sistema en microservicios ayuda o estorba?
Ejercicios de razonamiento
En estas preguntas no se te pide recordar una definición: se te da un escenario y debés razonar la respuesta antes de comprobarla.
La suite de EventoUAB tiene 120 pruebas, casi todas de extremo a extremo con navegador. Tarda 25 minutos y falla a menudo "por nada": a veces el navegador no carga a tiempo. Nadie la corre antes de integrar.
¿Qué cambio de estrategia tiene más sentido?
Mateo revisa un Pull Request que sube la cobertura de 62 % a 94 %. Al leer las pruebas nuevas, ve que la mayoría llama a funciones y no tiene ningún assert.
¿Cómo debería responder en la revisión?
Actividades de repaso
Un repaso integrador de todo el módulo. Cada actividad se corrige al instante; tu progreso se guarda automáticamente en este navegador.
¿Por qué la base de la pirámide de pruebas debe tener muchas pruebas unitarias?
Un informe de cobertura del 100 % garantiza que el código no tiene errores.
Querés comprobar que, al rechazar una reserva, el sistema no envía ningún correo. ¿Qué doble usás para el notificador?
Se corrige un bug reportado por un usuario. ¿Qué es lo primero que debería hacerse?
Cheat Sheet
| Término | Idea clave |
|---|---|
| Pirámide de pruebas | Muchas unitarias (rápidas), algunas de integración, pocas de extremo a extremo (lentas y frágiles). |
| Prueba unitaria | Prueba una pieza sola, con sus dependencias reemplazadas por dobles. |
| Stub | Respuestas fijas: para ver cómo reacciona tu código ante una situación. |
| Fake | Implementación simple pero real (lista en memoria): para comportamientos con estado. |
| Espía (mock) | Anota las llamadas: para comprobar que se hizo algo hacia afuera. No abusar de él para el orden interno. |
| Cobertura | Qué código se ejecutó. Baja = información confiable; alta = no garantiza nada sin asserts. |
| Regresión | Algo que funcionaba y dejó de funcionar. Se previene con una prueba que reproduce el bug (primero falla), se corrige, y queda en la suite. |
| Prueba de contrato | En microservicios, acuerdo verificable entre el servicio que pide y el que responde. |
¿Sabías que...?
🔺 La pirámide viene de Mike Cohn
La idea de la pirámide de pruebas se atribuye a Mike Cohn, en su libro Succeeding with Agile (2009), y sigue siendo la referencia para equilibrar tipos de prueba.
🧬 Existen las pruebas de mutación
El experimento de cambiar > por >= a propósito está automatizado en herramientas de mutation testing (como Stryker): modifican el código y miden cuántos cambios detectan tus pruebas.
🧭 Lo que viene en la próxima guía
Con la suite bajo control, toca refactorizar: cambiar la estructura del código sin cambiar su comportamiento, y apoyarte en estas mismas pruebas para saber que no rompiste nada.