10. Primer proyecto práctico (Node.js + Express)

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.

10.1 El proyecto y sus criterios de aceptación

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:

Contrato estable

GET /health responderá 200 con estado, tiempo activo y fecha ISO.

Errores consistentes

Una ruta inexistente devolverá 404 con JSON previsible.

Arranque separado

La aplicación no abrirá un puerto al importarse; server.js tendrá esa responsabilidad.

Pruebas deterministas

Reloj y uptime se inyectarán para no depender del momento de ejecución.

Configuración externa

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.

10.2 Preparar Node.js y las dependencias

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.

10.3 Estado inicial limpio e instrucciones del proyecto

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.

10.4 Planificar antes de editar

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.

Preguntas para revisar el plan
PreguntaDecisión esperadaRiesgo 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.

10.5 Estructura que debería producir el agente

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.

10.6 Construir el contrato HTTP

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}`);
});

10.7 Probar con Jest y Supertest

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.

10.8 Ejecutar, inspeccionar y corregir

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.

10.9 Segunda iteración: comprobar disponibilidad

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.

10.10 Configuración y límites del ejemplo

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.

10.11 Revisión final y commit

Lista de control antes de aceptar el trabajo
ControlAcciónResultado esperado
Suitenpm testTodos pasan y Jest termina sin procesos abiertos.
Prueba manualAbrir /health, /ready y una ruta inválidaCódigos y JSON correctos.
Cambiosgit status --short y git diffSin secretos, logs ni node_modules.
DependenciasRevisar los dos archivos de npmNinguna incorporación injustificada.
SeparaciónBuscar listen(Aparece sólo en server.js.
RecuperaciónDoble Esc / /rewindVolver 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"

10.12 Desafíos para continuar

  • Agregar un encabezado X-Request-Id y probar su propagación.
  • Incorporar middleware de duración sin hacer frágil el test.
  • Capturar errores inesperados sin exponer stacks al cliente.
  • Extraer la validación de configuración a una función pura y probar sus límites.
  • Agregar cobertura con Jest y revisar primero qué ramas valiosas faltan.
  • Usar 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.