Las pruebas te dicen que algo está roto. Casi nunca te dicen por qué. Una prueba de integración que esperaba 201 y recibió 500 te ha hecho un favor enorme —ha detectado el fallo antes que un cliente—, pero ahora hay que averiguar qué ocurrió dentro de esa petición: en qué middleware, con qué datos, en qué línea.

Esta lección cierra el módulo con la otra mitad del oficio: las herramientas y el método para diagnosticar. Una frontera antes de empezar, para que no haya confusión: aquí diagnosticamos lo que falla. Medir y optimizar lo que funciona pero va lento —perfiles de CPU, flamegraphs, pruebas de carga— es el módulo 10.

Contenido

  1. Del console.log al depurador
  2. El depurador integrado: --inspect
  3. Depurar desde VS Code, incluidas las pruebas de Mocha
  4. Puntos de interrupción y ejecución paso a paso
  5. Depurar código asíncrono
  6. Leer una traza de error de verdad
  7. Errores frecuentes y su diagnóstico
  8. Fugas de memoria
  9. Depurar en producción sin parar el servicio
  10. Método de depuración

Del console.log al depurador

Empecemos por lo obvio: console.log no es vergonzoso. Es instantáneo, funciona en cualquier entorno, no requiere configuración y muchas veces resuelve el problema en treinta segundos. Lo que sí es cierto es que obliga a modificar el código, a reiniciar, a adivinar de antemano qué imprimir, y con objetos anidados imprime [Object] justo donde estaba la respuesta. Aprovecha primero todo lo que la consola ofrece más allá de log:

const util = require('node:util');

console.table(evento.sesiones.map((s) => ({   // comparativas legibles de un vistazo
  id: s.id, aforo: s.aforo, libres: s.libres,
  ocupacion: `${(s.ocupacion * 100).toFixed(1)} %`,
})));

console.dir(pedido, { depth: null, colors: true });   // el remedio contra [Object]
registro.debug(util.inspect(pedido, { depth: 4, breakLength: 120 })); // igual, como texto

console.time('compra');                     // cuánto tarda un tramo
await comprarEntradas(datos);
console.timeEnd('compra');                  // compra: 187.42ms

console.trace('quien esta llamando a vender()');  // cómo se llegó aquí, sin lanzar
console.count('venta-registrada');                // cuántas veces pasa por aquí
console.assert(sesion.vendidas <= sesion.aforo, 'SOBREVENTA', sesion.id);

console.dir(objeto, { depth: null }) es probablemente el truco más rentable de la lista: por defecto Node solo imprime dos niveles de anidamiento, y con un pedido que contiene líneas que contienen entradas esos dos niveles se agotan enseguida.

Aun así, el depurador da tres cosas que la consola no puede dar:

console.log Depurador
Decidir qué mirar Antes de ejecutar Durante la ejecución
Ver el ámbito Solo lo que imprimiste Todas las variables vivas
Pila de llamadas console.trace Navegable, con el ámbito de cada marco
Modificar valores No Sí, desde la consola en vivo
Coste en producción Contamina los registros Cero si no está activo

El depurador integrado: --inspect

Node lleva un depurador incorporado que habla el protocolo de Chrome DevTools, y se activa con dos banderas:

node --inspect src/servidor.js       # arranca y activa el inspector
node --inspect-brk src/servidor.js   # arranca y SE DETIENE en la primera línea
# Debugger listening on ws://127.0.0.1:9229/8f2a1c3d-...

La diferencia es decisiva. --inspect sirve para depurar algo que ocurre más tarde —una petición HTTP concreta, un evento—: el servidor arranca y espera. --inspect-brk sirve para depurar el arranque: la lectura de la configuración, la conexión a la base de datos, el montaje de los middlewares; sin -brk, todo eso ya ha pasado antes de que te dé tiempo a conectarte.

Para conectarte desde Chrome o Edge, abre chrome://inspect, busca tu proceso en "Remote Target" y pulsa inspect: se abre DevTools con la pestaña Sources, donde puedes poner puntos de interrupción, y con la pestaña Memory, que usaremos para las fugas. Existe también un depurador de línea de comandos, node inspect src/servidor.js, útil por SSH donde no hay navegador: c continúa, n avanza una línea, s entra en la función, o sale, repl evalúa expresiones. Es austero, pero un día en un servidor remoto te salvará la tarde.

Depurar desde VS Code, incluidas las pruebas de Mocha

Esta es la forma más productiva en el día a día. En .vscode/launch.json:

{
  "version": "0.2.0",
  "configurations": [
    {
      "name": "Servidor Escena Viva",
      "type": "node", "request": "launch",
      "program": "${workspaceFolder}/src/servidor.js",
      "envFile": "${workspaceFolder}/.env",
      "skipFiles": ["<node_internals>/**", "${workspaceFolder}/node_modules/**"],
      "console": "integratedTerminal"
    },
    {
      "name": "Mocha: el fichero abierto",
      "type": "node", "request": "launch",
      "program": "${workspaceFolder}/node_modules/mocha/bin/mocha.js",
      "args": ["${relativeFile}", "--timeout", "0"],
      "envFile": "${workspaceFolder}/.env.test",
      "skipFiles": ["<node_internals>/**", "${workspaceFolder}/node_modules/**"],
      "console": "integratedTerminal"
    },
    {
      "name": "Adjuntar a proceso existente",
      "type": "node", "request": "attach", "port": 9229, "restart": true,
      "skipFiles": ["<node_internals>/**"]
    }
  ]
}

La segunda configuración —abres pedidos.test.js, pones un punto de interrupción y pulsas F5— es la más usada de todas; basta duplicarla sin ${relativeFile} para depurar la batería completa. La tercera sirve cuando el proceso ya corre con --inspect, por ejemplo dentro de un contenedor.

Tres detalles marcan la diferencia. "--timeout", "0" es imprescindible en las configuraciones de Mocha: sin él, Mocha aborta la prueba a los 5000 ms mientras tú estás parado leyendo variables. skipFiles con node_modules y <node_internals> evita que el paso a paso te meta dentro de Express o de Mongoose, de modo que al "entrar en la función" entres en tu código. Y envFile apuntando a .env.test garantiza que depuras contra la base de pruebas y no contra la de desarrollo.

Depurar las pruebas es, con diferencia, el caso más útil: tienes un fallo reproducible de forma determinista, aislado, con datos conocidos, y puedes pararlo donde quieras. Es la situación ideal para depurar, y es un regalo que te ha hecho el resto del módulo.

Puntos de interrupción y ejecución paso a paso

Hay tres tipos de punto de interrupción, y solo se usa el primero. El normal para siempre en esa línea. El condicional para solo si se cumple una expresión, y es la herramienta que convierte una tarde en cinco minutos: con la condición sesion.id === 'ses-001-1' && cantidad > 4, en un bucle sobre 7 sesiones y 1811 ventas paras exactamente en el caso que investigas (sus variantes son el contador de aciertos, que para en la llamada número 50, y la expresión de registro). El de registro (logpoint) no detiene nada: imprime un mensaje con interpolación, Vendiendo {cantidad} de {sesion.id}, quedan {sesion.libres}, cada vez que pasa por ahí. Es un console.log que no toca el código fuente, perfecto cuando el problema solo aparece bajo concurrencia y detener la ejecución lo haría desaparecer.

Los controles son continuar (F5, sigue hasta el próximo punto), paso por encima (F10, ejecuta la línea sin entrar en las funciones que llame), paso dentro (F11) y paso fuera (Shift+F11, termina la función actual y vuelve al llamante).

Y los cuatro paneles: Variables muestra el ámbito local, el de cierre y el global en el punto donde estás parado, y es donde descubres que req.body es undefined; Inspección sigue expresiones concretas en cada paso (sesion.libres, req.usuario.rol); Pila de llamadas reconstruye cómo se llegó hasta aquí y puedes hacer clic en cualquier marco para ver sus variables, que suele ser donde está el fallo real; y la Consola de depuración evalúa cualquier expresión en el contexto actual.

// Escrito en la consola de depuración, con la ejecución detenida:
sesion.libres                        // 2
sesion.libres >= req.body.cantidad   // false  <-- aquí está el 409
req.usuario.rol = 'administrador'    // cambiar en vivo y continuar para ver qué pasa

Esa última línea es una capacidad enorme: comprobar una hipótesis sin editar, guardar y reiniciar.

Depurar código asíncrono

En Node, la mitad de los problemas ocurren después de un await, y ahí la depuración tiene una peculiaridad. La pila se rompe porque cuando registras un callback con setTimeout o resuelves una promesa, la función se ejecuta más tarde, en otro turno del bucle de eventos, y para entonces la pila original ya se desmontó. Por eso una traza clásica muestra tres marcos (Timeout._onTimeout, listOnTimeout, processTimers), ninguno útil: no dice quién programó ese timeout.

Las trazas asíncronas son la solución. Node mantiene información que permite reconstruir la cadena lógica a través de los await, y DevTools y VS Code la muestran con separadores:

Error: Aforo insuficiente
    at Sesion.vender (/app/src/dominio/sesion.js:58:11)
    at comprarEntradas (/app/src/servicios/pedidos.js:34:19)
--- await ---
    at crearPedido (/app/src/controladores/pedidos.js:22:24)
--- await ---
    at Layer.handle (/app/node_modules/express/lib/router/layer.js:95:5)

Los marcadores --- await --- separan los tramos asíncronos y se leen de arriba abajo como pasado hacia atrás: el error se lanzó en vender, llamado desde comprarEntradas, que fue esperado desde crearPedido.

Cuatro consejos prácticos. async/await produce trazas mucho mejores que los callbacks anidados, una razón más para preferirlo. Nunca uses catch vacíos: borran la única pista que tenías. Preserva la causa al reenvolver un error, que es lo que hace útil una jerarquía como la nuestra. Y captura los rechazos no gestionados en el arranque para que nunca se pierdan en silencio:

try {
  await repositorioPedidos.guardar(pedido);
} catch (error) {
  // La opción cause encadena la traza original: sin ella, la pierdes
  throw new ErrorDeAplicacion('No se pudo guardar el pedido', {
    codigo: 'ERROR_PERSISTENCIA', cause: error,
  });
}

// src/servidor.js
process.on('unhandledRejection', (razon) => {
  console.error('Promesa rechazada sin gestionar:', razon);
  process.exit(1);   // fallar rápido: un estado desconocido no es seguro
});

Leer una traza de error de verdad

Una traza real está llena de ruido. Vamos a diseccionar una:

ValidationError: Pedido validation failed: lineas.0.cantidad: Path `cantidad` (7)
is more than maximum allowed value (6).
    at model.Document.invalidate (/app/node_modules/mongoose/lib/document.js:3241:32)
    at /app/node_modules/mongoose/lib/schemaType.js:1368:9
    at process.processTicksAndRejections (node:internal/process/task_queues.js:77:11)
    at async repositorioPedidos.crear (/app/src/repositorios/pedidos.js:47:20)
    at async comprarEntradas (/app/src/servicios/pedidos.js:61:19)
    at async crearPedido (/app/src/controladores/pedidos.js:22:24)

Se lee en este orden. El tipo y el mensaje primero: un ValidationError de Mongoose con el campo exacto (lineas.0.cantidad), el valor recibido (7) y la regla violada (máximo 6); el 80 % de las veces la primera línea ya lo dice todo. Después salta el ruido de node_modules y node:internal, porque no vas a arreglar Mongoose. Luego busca el primer marco de /app/src/ contando desde arriba —repositorios/pedidos.js:47—, que es donde tu código provocó el error, y sigue bajando por tus marcos para reconstruir el camino: repositorio ← servicio ← controlador. Diagnóstico: alguien pidió 7 entradas y la validación de zod no lo detuvo antes de llegar al modelo, así que el fallo real no es de Mongoose sino un agujero en la validación de entrada, y probablemente el cliente reciba un 500 en vez del 422 que le corresponde.

Dos herramientas para controlar las trazas. Node guarda 10 marcos por defecto, corto para cadenas asíncronas largas: se amplía con node --stack-trace-limit=50 o con Error.stackTraceLimit = 50. Y Error.captureStackTrace(this, this.constructor) en el constructor de ErrorDeAplicacion elimina de la traza los marcos del propio constructor, un detalle pequeño que hace que tus errores señalen directamente al código que los lanzó.

Errores frecuentes y su diagnóstico

Todos estos han aparecido ya a lo largo del curso. Esta tabla es tu referencia de consulta:

Error Causa habitual en Escena Viva Cómo diagnosticarlo
ERR_HTTP_HEADERS_SENT Un res.json() sin return seguido de next(error) Punto de interrupción en el segundo envío; busca el return que falta
EADDRINUSE Un npm run dev olvidado, o dos pruebas con puerto fijo lsof -i :3000 o ss -ltnp y matar el proceso; puerto efímero en pruebas
ECONNREFUSED Mongo o PostgreSQL parados, o URI equivocada en .env Comprueba el servicio y la variable; imprime configuracion al arrancar
ETIMEDOUT Proveedor externo lento, cortafuegos, AbortSignal.timeout corto Sube el tiempo para confirmar; después revisa la red, no el timeout
MODULE_NOT_FOUND Extensión olvidada, ruta relativa mal, node_modules desactualizado El mensaje trae la ruta buscada: compárala carácter a carácter
unhandledRejection await olvidado, o async sin propagar Registra el manejador de process; la traza indica el punto
CastError GET /api/eventos/no-existe con Mongoose Validar el parámetro con zod y devolver 400 o 404, no un 500
E11000 duplicate key Dos usuarios con el mismo correo; semilla ejecutada sin limpiar El mensaje trae el índice; tradúcelo a ConflictoDeEstado en el repositorio
El proceso no termina Conexión a Mongo sin cerrar, setInterval vivo, servidor escuchando why-is-node-running (abajo)
La petición se queda colgada El next() olvidado del módulo 6 Punto de registro en cada middleware: el último que imprime es el culpable

Los dos últimos merecen desarrollo. Con "exit": false en .mocharc.json, el proceso que no termina aparece en cuanto olvidas cerrar algo:

// test/ayudas/preparacion.js, temporalmente mientras investigas
const porQueSigueVivo = require('why-is-node-running');

exports.mochaHooks = {
  async afterAll() {
    await desconectar();
    setTimeout(() => porQueSigueVivo(), 1000).unref();
  },
};

Imprime cada handle y request abierto con la traza del sitio donde se creó, y suele ser una de tres cosas: la conexión de Mongoose, un setInterval de una caché o de un limpiador de tokens, o un http.Server creado a mano. La alternativa sin instalar nada es imprimir process._getActiveHandles().length en un after: es API interna y sin documentar, pero para depurar sirve.

Para la petición colgada, el diagnóstico más rápido es un middleware temporal de trazado, app.use(trazar('json')), app.use(trazar('autenticar')), etcétera, donde trazar(etiqueta) imprime [${req.idPeticion}] pasa por ${etiqueta} y llama a next(). El último trazar que aparece en la consola señala al middleware siguiente como culpable.

Fugas de memoria

Una fuga es memoria que se retiene y ya no se usa. En un script no importa; en un servidor que corre semanas, acaba en un reinicio por falta de memoria. Los síntomas: la memoria del proceso crece de forma sostenida y no baja tras las horas valle, el rendimiento se degrada poco a poco porque el recolector trabaja cada vez más, y finalmente llega JavaScript heap out of memory.

El primer diagnóstico no necesita herramientas: un setInterval temporal que imprima process.memoryUsage() cada minuto. De sus cuatro campos, rss es la memoria total del proceso, heapTotal lo reservado por V8, external los Buffers, y heapUsed es el que importa. La clave no es el valor absoluto sino la tendencia durante horas: que suba y baje es normal, que solo suba no lo es.

El diagnóstico serio es comparar dos instantáneas del montículo. Arranca con node --inspect, ve a chrome://inspect, pestaña Memory, y toma la instantánea A. Ejercita la aplicación repitiendo varias veces el mismo ciclo —compras, listados, logins— y toma la instantánea B. Selecciona B y cambia la vista a Comparison contra A: la columna Delta muestra qué tipos de objeto han crecido sin liberarse, y si tras diez ciclos idénticos ves 10.000 objetos Sesion más o 500 listeners más, ahí está tu fuga. La sección Retainers te dice quién mantiene viva la referencia, que es la información que realmente necesitas. También puedes generarla desde el código con v8.writeHeapSnapshot(), útil en un servidor sin navegador: devuelve la ruta de un fichero .heapsnapshot que después se descarga y se carga en DevTools.

Las causas típicas, todas ya vistas en este curso:

Causa Cómo ocurre en Escena Viva Solución
Oyentes no desregistrados (M2) Cada petición hace gestorDeVentas.on('venta-registrada', ...) y nadie hace off Usar once u off explícito; vigilar MaxListenersExceededWarning
Caché sin límite (M4) La caché de cambio-divisas.js guarda cada divisa y nunca expira Límite de tamaño (LRU) y caducidad por entrada
Variables de módulo que acumulan Un array de "últimas peticiones" al que solo se hace push Tamaño máximo, o mover a Redis (módulo 10)
Cierres que retienen Un callback que captura el req entero y vive en un temporizador Capturar solo lo necesario (req.idPeticion, no req)
Temporizadores no cancelados setInterval por sesión de usuario clearInterval al terminar; unref() cuando proceda

El aviso MaxListenersExceededWarning: Possible EventEmitter memory leak detected. 11 venta-registrada listeners added es un regalo de Node: te está diciendo exactamente dónde mirar. No lo silencies subiendo setMaxListeners; investiga por qué se añaden.

Depurar en producción sin parar el servicio

En producción no puedes poner un punto de interrupción: detendrías las peticiones de todos los usuarios. Las técnicas son otras.

1. Registros estructurados con identificador de petición. Es, con diferencia, la herramienta más valiosa. El middleware id-peticion.js del módulo 6 asigna un identificador único a cada petición y registro-peticiones.js lo incluye en cada línea:

{"nivel":"info","idPeticion":"9f3c1e","metodo":"POST","ruta":"/api/pedidos","usuarioId":"usr-001","mensaje":"compra iniciada"}
{"nivel":"warn","idPeticion":"9f3c1e","codigo":"AFORO_INSUFICIENTE","sesionId":"ses-001-1","libres":2,"solicitadas":4}
{"nivel":"info","idPeticion":"9f3c1e","estado":409,"duracionMs":91}

Con ese idPeticion reconstruyes el recorrido completo de una petición concreta entre miles de líneas simultáneas: cuando Lucía escriba diciendo que no pudo comprar a las 18:22, tienes su historia entera filtrando por un campo. La monitorización y la agregación de estos registros se cierran en el módulo 11. Regla de oro: nunca registres datos personales, contraseñas, tokens ni cabeceras Authorization, porque un registro es un fichero que muchas personas pueden leer y que a menudo se envía a un tercero.

2. Activar el inspector con una señal, temporalmente. kill -USR1 <pid> hace que Node abra el inspector en el 9229 sin reiniciar el proceso, y te conectas por un túnel SSH: ssh -L 9229:localhost:9229 usuario@servidor, y después chrome://inspect en tu máquina local ve el proceso remoto.

3. Nunca dejes --inspect abierto en un servidor público. Esto es una advertencia de seguridad seria, no una recomendación de estilo: el puerto del inspector no tiene autenticación, así que cualquiera que llegue a él puede evaluar código arbitrario en tu proceso, leer memoria —incluidos los secretos JWT y las contraseñas de la base de datos— y modificar el comportamiento en vivo. Es ejecución remota de código servida en bandeja. Las reglas mínimas: nunca --inspect=0.0.0.0:9229 (por defecto escucha solo en 127.0.0.1, déjalo así), accede siempre por túnel SSH, ciérralo en cuanto termines, y que el cortafuegos bloquee el 9229 desde el exterior como red de seguridad.

4. Volcados de memoria bajo demanda, mediante un endpoint interno protegido por rol de administrador y accesible solo desde la red privada que llame a v8.writeHeapSnapshot(). Permite investigar una fuga en producción sin tocar el servicio.

Conviene dejar clara la frontera, porque la confusión es habitual:

Depuración (este módulo) Perfilado (módulo 10)
Pregunta ¿Por qué hace algo incorrecto? ¿Por qué tarda tanto?
Síntoma Error, resultado erróneo, cuelgue Lentitud, poca capacidad
Herramientas Inspector, puntos de interrupción, trazas, registros Perfiles de CPU, flamegraphs, autocannon, clinic
Resultado Un fallo localizado y una prueba que lo reproduce Un cuello de botella cuantificado

Método de depuración

Las herramientas sin método producen tardes perdidas. Este es el procedimiento, en orden.

1. Reproducir. Un fallo que no puedes reproducir no puedes arreglarlo; a lo sumo puedes cambiar cosas hasta que parezca que desaparece. Reúne petición exacta, usuario y rol, datos implicados, hora e idPeticion; si es intermitente, ejecútalo en bucle hasta que caiga.

2. Aislar. Reduce hasta el mínimo que sigue fallando: ¿falla sin autenticación?, ¿con otra sesión?, ¿solo con evt-002? Cada respuesta elimina medio bosque.

3. Formular una hipótesis falsable. No "algo raro pasa con el aforo", sino "creo que vender() compara con < en lugar de <= y por eso rechaza la última entrada". Una hipótesis que no se puede refutar no sirve de nada.

4. Comprobarla con un punto de interrupción, un punto de registro o una consulta. Si era falsa, no la retuerzas: formula otra. Aferrarse a una hipótesis equivocada es la causa número uno de las depuraciones largas.

5. Bisección con git bisect cuando sepas que antes funcionaba:

git bisect start
git bisect bad                    # el commit actual falla
git bisect good v1.4.0            # esta versión funcionaba
git bisect run npm run test:unidad  # automático: Git usa el código de salida
git bisect reset

Con 1000 commits entre el bueno y el malo, git bisect encuentra el culpable en 10 pasos, y con run se va a por un café y vuelve con el commit exacto. Fíjate en lo que implica: git bisect automático solo funciona si tienes pruebas. Es otro beneficio de este módulo que no se ve hasta que lo necesitas.

6. Escribir una prueba que reproduzca el fallo, antes de arreglarlo. Este paso cierra el círculo del módulo entero y es innegociable. El orden importa: escribes la prueba y debe fallar —si pasa, no has entendido el fallo y estás a punto de arreglar otra cosa—, después arreglas el código, la prueba pasa, y por último ejecutas la batería completa para comprobar que el arreglo no rompió nada más.

// Incidencia 481: un pedido de exactamente las últimas localidades devolvía 409.
// Esta prueba falló en rojo antes del arreglo (vender comparaba con < en vez de <=).
it('permite comprar exactamente las ultimas localidades libres', async () => {
  const lucia = await comoAsistente();
  await ajustarAforo('ses-001-1', { libres: 2 });

  const { body } = await request(app).post('/api/pedidos').set(...lucia.cabecera)
    .send({ sesionId: 'ses-001-1', cantidad: 2 }).expect(201);
  expect(body.entradas).to.have.lengthOf(2);

  const sesion = await request(app).get('/api/sesiones/ses-001-1').expect(200);
  expect(sesion.body.libres).to.equal(0);
  expect(sesion.body.agotada).to.be.true;
});

Tres cosas ganas con esa prueba: demuestras que entendiste el fallo, garantizas que no volverá nunca y dejas documentado un caso límite que a nadie se le había ocurrido. Un fallo en producción es caro; desperdiciarlo sin convertirlo en una prueba es tirar el dinero.

Errores Comunes y Consejos

  • Depurar sin reproducir. Cambiar código hasta que "parece que ya va" no arregla nada: lo esconde hasta el peor momento posible.
  • Olvidar --timeout 0 al depurar pruebas. Mocha aborta la prueba mientras estás parado leyendo variables y crees que el depurador está roto.
  • No usar skipFiles. Pulsas F11 y acabas dentro de express/lib/router/layer.js sin saber cómo salir.
  • catch vacíos. Borran la única información que tenías; registra siempre, aunque sea en nivel debug.
  • Perder la causa al reenvolver errores. Usa { cause: error }: la traza original vale más que un mensaje bonito.
  • Silenciar MaxListenersExceededWarning. Es un detector de fugas gratuito; subir el límite es tapar el aviso de incendio.
  • Dejar --inspect en un servidor accesible. Es ejecución remota de código sin autenticación. Túnel SSH siempre.
  • Registrar tokens o contraseñas. Un registro es un fichero que mucha gente lee y que suele salir de tu infraestructura.
  • Consejo: cuando una prueba de integración falle con 500, imprime respuesta.body y busca el idPeticion en la salida del servidor: tienes la historia completa en dos pasos. Y aprende bien los puntos de interrupción condicionales, que es la habilidad que más tiempo ahorra de toda la lección.

Ejercicios

Ejercicio 1: diagnosticar sin ejecutar

Para cada síntoma, indica la causa más probable, la herramienta con la que lo confirmarías y el arreglo:

  1. npm test imprime 74 passing y el proceso se queda colgado sin devolver el prompt.
  2. POST /api/pedidos responde 500 con Cannot set headers after they are sent to the client.
  3. GET /api/eventos/hola devuelve 500 en lugar de 404.
  4. El servidor lleva cinco días arriba y heapUsed ha pasado de 90 MB a 780 MB.
  5. Una prueba de integración pasa sola y falla dentro de la batería completa.

Ejercicio 2: depurar una prueba con VS Code

Toma la prueba de concurrencia de 09-04. Configura .vscode/launch.json con la entrada "Mocha: el fichero abierto", coloca un punto de interrupción condicional dentro del repositorio de pedidos que solo se active cuando libres < 2, y describe qué verías en el panel de pila de llamadas y en el de variables. Explica por qué --timeout 0 es imprescindible aquí.

Ejercicio 3: del fallo a la prueba

Un organizador de la Sala Bóveda informa de que el informe de evt-002 muestra ingresosCentimos: NaN desde ayer. Diseña el proceso completo: cómo reproducirlo, cómo aislarlo, dos hipótesis falsables, cómo usarías git bisect, y escribe la prueba que reproduce el fallo (debe fallar antes del arreglo).

Soluciones

Ejercicio 1. (1) Un handle abierto, casi seguro la conexión a Mongo sin cerrar; confírmalo con why-is-node-running en afterAll y arréglalo con await desconectar() en el gancho raíz. (2) Doble respuesta: un manejador hace res.json(...) y luego llama a next(error), o hay un await tras responder que lanza; punto de interrupción en manejadorDeErrores y mira la pila; el arreglo es return res.json(...). (3) CastError de Mongoose: hola no es un ObjectId válido y el error llega como desconocido, que ESTADO_POR_CODIGO traduce a 500; valida el parámetro con zod o traduce CastError a RecursoNoEncontrado en el repositorio. (4) Fuga de memoria: dos instantáneas del montículo comparadas tras ciclos idénticos, con los oyentes acumulados en GestorDeVentas y la caché sin límite de cambio-divisas.js como principales sospechosos. (5) Contaminación entre pruebas: un stub sin restaurar, un reloj falso vivo o datos que otra prueba dejó; confírmalo ejecutando la batería en orden inverso y arréglalo con sinon.restore() en el gancho raíz y limpieza en afterEach.

Ejercicio 2. El punto de interrupción condicional se coloca en la línea del repositorio donde se comprueba el aforo, con la condición sesion.libres < 2. En el panel de pila de llamadas verías, de arriba abajo, el método del repositorio, comprarEntradas, el controlador crearPedido y varios marcos de Express separados por marcadores --- await ---; haciendo clic en el marco del controlador verías req.usuario y req.datosValidados de esa petición concreta. En el panel de variables verías sesion.libres con el valor exacto en el instante en que una de las diez compras simultáneas encuentra el aforo casi agotado, y podrías comprobar en la consola si la comparación con cantidad da lo que esperabas.

--timeout 0 es imprescindible por una razón específica: la prueba lanza diez peticiones con Promise.all, y mientras estás parado en una las otras nueve siguen esperando; con el timeout normal de 5000 ms, Mocha abortaría la prueba entera a los cinco segundos, cerrando la aplicación bajo tus pies.

Ejercicio 3. Reproducir: llamar a GET /api/eventos/evt-002/informe con un organizador de org-boveda en la base de pruebas sembrada; si no se reproduce, copiar los datos reales de evt-002, porque entonces el fallo está en los datos y no en el código. Aislar: probar con evt-001 y evt-003; si solo falla evt-002 —el de las tres sesiones—, calcular el informe sesión a sesión para encontrar cuál produce NaN. Hipótesis falsables: que una sesión tenga precioCentimos nulo y el reduce propague NaN (porque undefined * 2 es NaN y NaN contamina toda la suma), o que el informe sume precioEuros en lugar de precioCentimos. Bisección: como "desde ayer" acota la ventana, git bisect start, bad HEAD, good <commit de anteayer> y git bisect run npm run test:unidad.

it('calcula los ingresos aunque una sesion no tenga precio asignado', () => {
  const evento = crearEvento({
    id: 'evt-002',
    sesiones: [
      { vendidas: 120, precioCentimos: 1800 },
      { vendidas: 95, precioCentimos: 1800 },
      { vendidas: 60, precioCentimos: undefined },   // la sesión problemática
    ],
  });
  expect(Number.isNaN(evento.ingresosCentimos)).to.be.false;
  expect(evento.ingresosCentimos).to.equal(387000);  // (120 + 95) * 1800
});

Esta prueba falla en rojo antes del arreglo, que tiene dos partes y ambas importan: tratar el precio ausente como cero en el cálculo (s.precioCentimos ?? 0) y hacer que el modelo exija precioCentimos, para que el dato inválido no pueda volver a entrar. Arreglar solo el síntoma dejaría la puerta abierta.

Conclusión

Con esta lección se cierra el módulo 9, y con él se cierra una brecha que llevaba abierta desde el módulo 5.

Escena Viva ya no descansa sobre la confianza. npm test está verde y significa algo: el dominio calcula bien el aforo y los totales en céntimos, la matriz de permisos está recorrida celda por celda —aquel if invertido en puedeGestionarEvento que abriría el catálogo entero ahora pone varias pruebas en rojo—, el conversor de divisas degrada con elegancia cuando el proveedor se cae, la API responde 401, 403, 404, 409, 422 y 400 con el formato de error acordado, un asistente no puede ver el pedido de otro, y diez compras simultáneas por cinco butacas venden exactamente cinco butacas. La cobertura está medida con umbrales que solo suben, los scripts están organizados, y el proyecto puede ejecutarse solo en un servidor de integración continua. Y cuando algo falle —porque algo fallará—, sabes reproducirlo, aislarlo, ponerle un punto de interrupción condicional, leer su traza entre el ruido de node_modules, encontrar el commit culpable con git bisect y convertirlo en una prueba que impida su regreso.

Esa es la red de seguridad. A partir de ahora puedes cambiar el código sin miedo, que era exactamente la promesa con la que abrimos la primera lección.

Y sin embargo, la aplicación tiene un problema del que las pruebas no dicen ni una palabra, porque no es un problema de corrección: es correcta, pero es lenta y desaprovecha la máquina. Escena Viva usa un solo núcleo mientras el servidor tiene ocho, y en el estreno del Festival de Jazz de Primavera todos los procesos se pelearán por ese único hilo. Recalcula el mismo catálogo cientos de veces por minuto para devolver siempre lo mismo. Y cuando genera el PDF de una entrada, bloquea el bucle de eventos el tiempo suficiente para que las demás peticiones se queden esperando en la cola.

En el módulo 10, Temas Avanzados, atacamos justo eso: cluster para usar todos los núcleos, worker threads para sacar la generación de PDF del hilo principal, Redis para cachear el catálogo y encolar el trabajo pesado, optimización del rendimiento con perfiles de CPU, flamegraphs y pruebas de carga —la frontera que hemos respetado en este módulo—, y por último el diseño de APIs RESTful maduras y una introducción a GraphQL.

Curso de Node.js: De Principiante a Avanzado

Módulo 1: Introducción a Node.js

Módulo 2: Conceptos Básicos

Módulo 3: Sistema de Archivos y E/S

Módulo 4: HTTP y Servidores Web

Módulo 5: NPM y Gestión de Paquetes

Módulo 6: Framework Express.js

Módulo 7: Bases de Datos y ORMs

Módulo 8: Autenticación y Autorización

Módulo 9: Pruebas y Depuración

Módulo 10: Temas Avanzados

Módulo 11: Despliegue y DevOps

Módulo 12: Proyectos del Mundo Real

© Copyright 2026. Todos los derechos reservados