Objetivo del tema
Aplicar lo aprendido en un proyecto real: construiremos un servicio HTTP de observabilidad con Node.js y Express 5, cubierto con Jest y Supertest. Recorreremos el ciclo completo de Claude Code: preparar un estado seguro, persistir instrucciones, planificar, implementar, probar, diagnosticar, revisar y confirmar.
La primera versión expondrá un endpoint que informa si el proceso HTTP está vivo. Aunque el dominio es sencillo, lo trataremos como código de producción:
GET /health responderá 200 con estado, tiempo activo y fecha ISO.
Una ruta inexistente devolverá 404 con JSON previsible.
La aplicación no abrirá un puerto al importarse; server.js tendrá esa responsabilidad.
Reloj y uptime se inyectarán para no depender del momento de ejecución.
El servidor leerá PORT y usará 3000 como valor predeterminado.
Un liveness check confirma que el proceso responde. En la segunda iteración agregaremos /ready, que indicará si dispone de recursos para recibir tráfico.
Express 5 requiere Node.js 18 o superior. Comprueba las versiones y crea el proyecto:
node --version
npm --version
mkdir servicio-salud
cd servicio-salud
git init
npm init -y
npm install express@5
npm install --save-dev jest supertest
mkdir src
mkdir tests
Express es una dependencia de ejecución; Jest y Supertest sólo son necesarios durante el desarrollo. package-lock.json registra el árbol resuelto y debe guardarse en Git.
Ajusta los scripts y la versión mínima sin borrar las demás propiedades de package.json:
{
"scripts": {
"start": "node src/server.js",
"dev": "node --watch src/server.js",
"test": "jest --runInBand",
"test:watch": "jest --watch"
},
"engines": { "node": ">=18" }
}
Crea .gitignore:
node_modules/
coverage/
.env
npm-debug.log*
Por qué CommonJS: usaremos require() y module.exports para concentrarnos en Express sin sumar la configuración experimental que Jest todavía requiere para ciertos escenarios ESM.
git add -A
git commit -m "chore: inicializa proyecto Express"
claude
/init
Revisa el CLAUDE.md generado y complétalo:
# Servicio de salud
## Comandos de verificación
- Tests: `npm test`
- Desarrollo: `npm run dev`
- Producción local: `npm start`
## Convenciones
- Requiere Node.js 18 o superior y Express 5.
- Usa CommonJS (`require` y `module.exports`).
- Separa la construcción de la app del proceso que abre el puerto.
- Todas las respuestas JSON deben incluir `status`.
- Cada cambio de comportamiento debe incluir tests.
- No agregues dependencias sin justificar su necesidad.
## Alcance
- No agregar base de datos, autenticación, Docker ni TypeScript.
Versiona también este archivo. Registra reglas persistentes, no detalles pasajeros de una conversación.
Activa el modo Plan con Shift+Tab y entrega un contrato verificable:
Construye un servicio con Express 5. Necesito GET /health con código 200 y JSON { "status": "ok", "uptimeSeconds": <entero>, "timestamp": <ISO UTC> }. Una ruta desconocida debe devolver 404 con { "status": "error", "code": "NOT_FOUND" }. Exporta createApp() desde src/app.js e inicia PORT (3000 por defecto) sólo en src/server.js. Inyecta funciones now y uptime para que las pruebas no dependan del reloj real. Primero inspecciona y presenta un plan por archivos. Después escribe pruebas con Jest y Supertest para el camino feliz y el 404. Ejecuta npm test. No agregues dependencias.
El pedido define rutas, códigos, JSON, arquitectura y verificación. Si el plan omite algo, corrígelo antes de autorizar ediciones.
| Pregunta | Decisión esperada | Riesgo que evita |
|---|---|---|
| ¿Importar la app abre un puerto? | No; sólo server.js llama a listen(). | Puertos ocupados y tests que no terminan. |
| ¿Los tests usan tiempo real? | No; inyectan reloj y uptime fijos. | Resultados intermitentes. |
| ¿Dónde se registra el 404? | Después de las rutas válidas. | Interceptar /health. |
| ¿Hace falta otra biblioteca? | No para este alcance. | Mantenimiento innecesario. |
servicio-salud/
|-- src/
| |-- app.js # factoría, rutas y middleware
| `-- server.js # configuración y puerto
|-- tests/
| `-- health.test.js
|-- .gitignore
|-- CLAUDE.md
|-- package-lock.json
`-- package.json
Supertest puede enviar solicitudes a Express sin abrir un socket. Si el agente propone controladores y servicios vacíos para un endpoint, pide que reduzca el diseño.
Una implementación clara de src/app.js:
const express = require('express');
function createApp({
now = () => new Date(),
uptime = () => process.uptime(),
} = {}) {
const app = express();
app.disable('x-powered-by');
app.get('/health', (req, res) => {
res.status(200).json({
status: 'ok',
uptimeSeconds: Math.floor(uptime()),
timestamp: now().toISOString(),
});
});
app.use((req, res) => {
res.status(404).json({ status: 'error', code: 'NOT_FOUND' });
});
return app;
}
module.exports = { createApp };
Las funciones predeterminadas usan valores reales; los tests pueden reemplazarlas sin tocar globales. Math.floor() explicita segundos enteros y toISOString() normaliza a UTC.
src/server.js sólo se ocupa del proceso:
const { createApp } = require('./app');
const port = Number.parseInt(process.env.PORT || '3000', 10);
const app = createApp();
app.listen(port, () => {
console.log(`Servicio disponible en http://localhost:${port}`);
});
const request = require('supertest');
const { createApp } = require('../src/app');
describe('servicio de salud', () => {
test('GET /health devuelve el contrato completo', async () => {
const app = createApp({
now: () => new Date('2026-01-15T12:00:00.000Z'),
uptime: () => 42.9,
});
const response = await request(app).get('/health');
expect(response.statusCode).toBe(200);
expect(response.headers['content-type']).toMatch(/json/);
expect(response.body).toEqual({
status: 'ok',
uptimeSeconds: 42,
timestamp: '2026-01-15T12:00:00.000Z',
});
});
test('una ruta desconocida devuelve un error JSON', async () => {
const response = await request(createApp()).get('/inexistente');
expect(response.statusCode).toBe(404);
expect(response.body).toEqual({ status: 'error', code: 'NOT_FOUND' });
});
});
Las pruebas atraviesan la frontera HTTP completa y verifican código, cabecera y cuerpo. Detectarán cualquier cambio accidental del contrato.
Evita pruebas permisivas: afirmar sólo que el código es 200 no comprueba el contrato. Inyectar entradas conocidas permite exigir una salida exacta y estable.
npm test
npm start
Abre http://localhost:3000/health o consulta desde otra terminal:
# Linux y macOS
curl -i http://localhost:3000/health
# Windows PowerShell
curl.exe -i http://localhost:3000/health
Comprueba también una ruta inexistente. Si algo falla, separa diagnóstico y corrección:
Analiza la salida del test. Explica la causa raíz, el criterio incumplido y el cambio mínimo. No edites todavía. Tras mi confirmación, aplica el cambio, ejecuta primero el test afectado y luego npm test.
Agregaremos un endpoint que decida si la instancia puede recibir tráfico:
Agrega GET /ready. Obtén la memoria libre mediante availableMemoryMb inyectable y compárala con minMemoryMb, 256 por defecto. Si es igual o superior responde 200 con status "ready"; si es menor, 503 con status "not_ready". Incluye ambos valores. En producción usa os.freemem(). Primero escribe dos tests deterministas, confirma el fallo esperado y luego implementa. No alteres /health. Ejecuta npm test.
test.each([
{ available: 512, expectedCode: 200, expectedStatus: 'ready' },
{ available: 128, expectedCode: 503, expectedStatus: 'not_ready' },
])('GET /ready con $available MB', async ({
available, expectedCode, expectedStatus,
}) => {
const app = createApp({
availableMemoryMb: () => available,
minMemoryMb: 256,
});
const response = await request(app).get('/ready');
expect(response.statusCode).toBe(expectedCode);
expect(response.body).toEqual({
status: expectedStatus,
availableMemoryMb: available,
minMemoryMb: 256,
});
});
test.each cubre ambas ramas sin duplicar preparación. La ruta debe registrarse antes del middleware 404.
El umbral puede llegar desde READY_MIN_MEMORY_MB. Su lectura y validación pertenecen a server.js:
const os = require('node:os');
const { createApp } = require('./app');
const port = Number(process.env.PORT || '3000');
const minMemoryMb = Number(process.env.READY_MIN_MEMORY_MB || '256');
if (!Number.isInteger(port) || port < 1 || port > 65535) {
throw new Error('PORT debe ser un entero entre 1 y 65535');
}
if (!Number.isFinite(minMemoryMb) || minMemoryMb < 0) {
throw new Error('READY_MIN_MEMORY_MB debe ser no negativo');
}
const app = createApp({
minMemoryMb,
availableMemoryMb: () => Math.floor(os.freemem() / 1024 / 1024),
});
app.listen(port);
El check de memoria es didáctico. En producción, /ready suele comprobar dependencias esenciales y debe ser rápido, no filtrar secretos ni convertir servicios opcionales en obligatorios.
Diferencia operativa: un fallo de /health suele pedir reiniciar el proceso; un 503 de /ready indica que temporalmente no debe recibir solicitudes.
| Control | Acción | Resultado esperado |
|---|---|---|
| Suite | npm test | Todos pasan y Jest termina sin procesos abiertos. |
| Prueba manual | Abrir /health, /ready y una ruta inválida | Códigos y JSON correctos. |
| Cambios | git status --short y git diff | Sin secretos, logs ni node_modules. |
| Dependencias | Revisar los dos archivos de npm | Ninguna incorporación injustificada. |
| Separación | Buscar listen( | Aparece sólo en server.js. |
| Recuperación | Doble Esc / /rewind | Volver al checkpoint si hubo desvíos. |
git add src tests package.json package-lock.json CLAUDE.md .gitignore
git commit -m "feat: agrega endpoints de salud y disponibilidad"
X-Request-Id y probar su propagación.claude -p para generar un README y revisarlo antes de guardarlo.Principio transferible: el agente produce mejores cambios cuando recibe límites arquitectónicos, exclusiones claras y pruebas observables.
Conclusión: convertiste una idea pequeña en una entrega profesional: estado recuperable, reglas en CLAUDE.md, plan revisado, app separada del servidor, dependencias inyectables, pruebas deterministas y commit atómico. En el próximo tema repetiremos el flujo con Python y FastAPI para distinguir qué cambia entre ecosistemas y qué prácticas permanecen.