13. Testing, logging y observabilidad en NestJS
Un backend no se juzga por lo que hace cuando todo va bien, sino por lo que puedes afirmar sobre él antes de desplegarlo y por lo que puedes averiguar cuando falla a las tres de la mañana. Este capítulo cubre las dos caras de la misma moneda: las pruebas, que verifican el comportamiento antes de producción, y la observabilidad —logs, métricas y trazas—, que lo explica cuando ya está en producción. Ambas son consecuencia directa del diseño: un servicio difícil de testear casi siempre está mal acoplado, y un sistema imposible de diagnosticar casi siempre es un sistema que nunca se instrumentó.
13.1 Qué vas a poder hacer al terminar
- Decidir con criterio qué merece la pena testear en una API y qué es ruido que envejece mal.
- Configurar Jest con alias de
tsconfig, SWC, umbrales de cobertura y diagnóstico de handles. - Escribir tests unitarios de servicios sustituyendo el
EntityManagery los repositorios de MikroORM con una fábrica de mocks tipada y reutilizable. - Testear guards, pipes, interceptores y filtros de forma aislada, con un
ExecutionContexty unCallHandlerfalsos. - Escribir tests de integración contra una base de datos real con aislamiento entre casos, eligiendo entre SQLite en memoria, Testcontainers o una base de datos de test dedicada.
- Escribir tests end-to-end con Supertest aplicando la misma configuración global que en producción.
- Generar datos de prueba con factorías y seeders en lugar de fixtures gigantes compartidos.
- Configurar logging estructurado en JSON con Pino, redactando campos sensibles, y propagar un identificador de correlación con
AsyncLocalStorage. - Instrumentar con métricas de Prometheus y trazas de OpenTelemetry, exponer health checks con Terminus y alertar sobre síntomas y no sobre causas.
- Levantar PostgreSQL real con Testcontainers, reutilizar el contenedor entre ficheros y elegir la estrategia de limpieza adecuada entre reversión, truncado y clonado de plantilla.
- Testear código transaccional y concurrente: atomicidad real, fallos a mitad de transacción, conflictos de bloqueo optimista y trabajos en cola.
- Neutralizar las entradas ocultas —el reloj, el azar y las tareas programadas— para que la suite sea reproducible.
- Proteger el contrato con los consumidores generando un cliente tipado desde OpenAPI y detectando cambios incompatibles en el pipeline.
- Diseñar pruebas de carga, estrés, resistencia y picos con k6, y leer la curva de latencia frente a concurrencia para localizar el punto de saturación.
- Depurar con el inspector de Node, perfilar CPU y memoria, y aplicar una metodología de diagnóstico.
- Diagnosticar en producción sin puntos de interrupción: nivel de log dinámico, muestreo dirigido, instantáneas del montículo y retraso del bucle de eventos.
- Definir SLI, SLO y presupuesto de error, correlacionar los tres pilares con un identificador de traza y distinguir las alertas útiles del ruido.
- Montar un pipeline de CI con PostgreSQL como servicio, caché y paralelismo.
13.2 Estrategia de testing en el backend
Antes de escribir una sola línea de test conviene responder a una pregunta incómoda: ¿qué estamos comprando con cada test? Un test es código de producción a todos los efectos —hay que mantenerlo, refactorizarlo y entenderlo— y su valor no es uniforme. Hay tests que evitan incidentes graves y tests que solo consiguen que cualquier refactor legítimo se ponga en rojo.
13.2.1 Qué merece la pena testear en una API
- Reglas de negocio. El corazón del sistema y lo único verdaderamente tuyo: «no se puede cerrar un proyecto con tareas pendientes», «un usuario solo vota una vez». Si se rompe, el daño es de datos.
- Casos límite. Colecciones vacías, valores frontera (
0,1, el máximo),nullfrente a ausente, fechas en el cambio de mes, unicode: los bugs viven en los extremos. - Autorización. Un test por endpoint que compruebe el
403del usuario sin permiso y el401del anónimo. Estos fallos no dan errores visibles: dan filtraciones silenciosas. Incluye siempre el caso «usuario autenticado accediendo al recurso de otro», el clásico IDOR. - Contratos HTTP. Códigos de estado, forma del cuerpo y cabeceras: tus clientes dependen de esa forma, y cambiarla sin darte cuenta es un breaking change.
- Consultas no triviales. Filtros combinados, paginación, ordenación, agregados y relaciones: aquí los mocks no valen nada y hay que hablar con una base de datos real (13.7).
- Bugs corregidos. Cada bug debería salir del sistema con un test que falle antes del arreglo; es la forma más barata de que no vuelva.
Y lo que no merece la pena testear: getters y setters triviales, que solo pueden fallar por un error que el compilador ya detecta; el framework (que @Get(':id') enruta, que
ValidationPipe valida un @IsEmail() o que MikroORM sabe hacer INSERT: eso ya tiene su suite, mantenida por gente que conoce ese código mejor que tú); mapeos uno a uno, que no
necesitan siete expect; detalles de implementación como «se llamó a flush
exactamente dos veces», que se rompen con el primer refactor correcto; y la configuración estática, porque si un módulo no importa a otro la aplicación no arranca y cualquier e2e lo revela.
13.2.2 La pirámide adaptada al backend
La pirámide de Mike Cohn (2009) sigue siendo válida en su intuición —cuanto más arriba, más caro y más lento— pero en un backend con base de datos la capa intermedia pesa mucho más de lo que la figura clásica sugiere. Por eso muchos equipos hablan hoy del «trofeo de tests» de Kent C. Dodds.
COSTE POR TEST ┌──────────────┐
(escribir · ejecutar · mantener) │ E2E HTTP │ 5–15 %
▲ │ Supertest │ ~200–2000 ms
│ │ AppModule │ Confianza: MUY ALTA
│ ┌────┴──────────────┴────┐
│ │ INTEGRACIÓN con BD │ 25–40 %
│ │ EntityManager real │ ~20–200 ms
│ │ módulo parcial │ Confianza: ALTA
│ ┌────┴────────────────────────┴────┐
│ │ UNITARIOS de servicios, │ 40–60 %
│ │ guards, pipes, interceptores │ ~1–10 ms
│ │ todo mockeado │ Confianza: MEDIA
│ ┌────┴──────────────────────────────────┴────┐
│ │ ESTÁTICO: tsc --noEmit, ESLint, tipos │ coste ~0
└───────────┴────────────────────────────────────────────┘ Confianza: BAJA
Regla práctica: sube un nivel solo cuando el nivel inferior NO PUEDE responder a la pregunta.
"¿Calcula bien el descuento?" → unitario
"¿Filtra y pagina bien esta query?" → integración (los mocks mienten)
"¿Está protegido este endpoint?" → e2e (el guard global solo existe en la app real)
| Nivel | Qué verifica | Coste | Qué NO detecta |
|---|---|---|---|
| Estático | Coherencia interna, contratos de tipos, promesas sin await | Casi nulo; corre al guardar | Nada del comportamiento en ejecución ni de los datos externos |
| Unitario | Lógica de negocio, ramas condicionales, errores lanzados, casos límite | Muy bajo: sin E/S | Errores de SQL, de esquema, de serialización, de orden de los pipes |
| Integración | Consultas, relaciones, restricciones, transacciones, migraciones, mapeo | Medio: exige esquema y limpieza | La capa HTTP: enrutado, validación, guards globales, filtros |
| E2E HTTP | El sistema como lo ve un cliente: estado, cuerpo, cabeceras, efectos, permisos | Alto: arranca la aplicación | Ramas internas raras; y la causa exacta («algo falló», no «dónde») |
expected 200, got 500. Se llega ahí por un razonamiento aparentemente sensato —«los e2e dan más confianza»— que ignora el coste de mantenimiento y de diagnóstico.
13.3 Jest en Nest: configuración con criterio
El starter del CLI de Nest configura Jest dentro de package.json. Funciona, pero en cuanto tengas dos configuraciones (unitarios y e2e), alias de rutas y transformadores, conviene sacarlo a un archivo propio con tipos y comentarios.
{
"jest": {
"rootDir": "src",
"testRegex": ".*\\.spec\\.ts$",
"transform": { "^.+\\.ts$": "ts-jest" }
}
}
// No admite comentarios ni lógica derivada de tsconfig.
// rootDir "src" impide tests de integración fuera de src.
// Sin moduleNameMapper los alias @app/* fallan en tests.
// Sin umbrales de cobertura ni setup compartido.import { pathsToModuleNameMapper } from 'ts-jest';
import { compilerOptions } from './tsconfig.json';
const config: Config = {
rootDir: '.', testEnvironment: 'node',
testRegex: '.*\\.spec\\.ts$',
moduleFileExtensions: ['js', 'json', 'ts'],
transform: { '^.+\\.ts$': ['ts-jest', { isolatedModules: true }] },
// Una sola fuente de verdad para los alias: tsconfig.json
moduleNameMapper: pathsToModuleNameMapper(compilerOptions.paths ?? {},
{ prefix: '<rootDir>/' }),
// jest.setTimeout, matchers propios, faker.seed y reloj fijo:
setupFilesAfterEnv: ['<rootDir>/test/setup-unit.ts'],
collectCoverageFrom: ['src/**/*.ts', '!src/**/*.module.ts',
'!src/**/*.dto.ts', '!src/**/*.entity.ts', '!src/main.ts'],
coverageThreshold: {
global: { branches: 70, functions: 75, lines: 80, statements: 80 },
'./src/domain/': { branches: 90, lines: 95 }, // lo que importa
},
};
export default config;pathsToModuleNameMapper
Importar ./tsconfig.json desde un .ts exige resolveJsonModule, y si tu
tsconfig.json usa extends, paths puede no estar en el archivo que importas: declara entonces el mapeo a mano. Cuidado también con el prefix: sin
<rootDir>/ los alias se resuelven relativos al archivo de test y fallan de forma desconcertante.
13.3.1 ts-jest frente a SWC
ts-jest compila con el compilador de TypeScript, así que comprueba los tipos (salvo con isolatedModules: true). @swc/jest transpila con SWC, escrito en Rust: entre 5 y 20 veces
más rápido al arrancar cada worker, pero solo borra los tipos. Para suites grandes la combinación ganadora es SWC en los tests más tsc --noEmit como paso aparte en CI.
{
"jsc": {
"target": "es2022",
"parser": { "syntax": "typescript", "decorators": true },
"transform": { "legacyDecorator": true, "decoratorMetadata": true },
"keepClassNames": true
},
"module": { "type": "commonjs" }
}
// Y en jest.config.ts: transform: { '^.+\\.ts$': '@swc/jest' }decoratorMetadata no es opcional
La inyección de dependencias de Nest y el mapeo de tipos de MikroORM leen los metadatos que emite el compilador (design:paramtypes, design:type). Sin esa opción los tests fallan con
Nest can't resolve dependencies of the XService (?), o las entidades pierden el tipo de sus propiedades. Y keepClassNames importa porque Nest y MikroORM usan el nombre de la clase como identificador durante el discovery.
13.3.2 --runInBand, workers y --detectOpenHandles
- Por defecto Jest paraleliza por archivo, un worker por núcleo. Es un gran acelerador para tests unitarios puros y una fuente de fallos intermitentes en cuanto hay estado compartido externo: una única base de datos, un puerto fijo, un archivo temporal, un Redis.
--runInBand(o-i) ejecuta todo en serie en el proceso principal: elimina esos conflictos y mejora las trazas de pila y el depurador, a costa de sumar todos los tiempos.--maxWorkers=2es el punto medio razonable en CI, donde los runners tienen dos núcleos y crear ocho workers solo añade contención.- La solución de fondo no es serializar, sino aislar: una base de datos o un esquema por worker con
process.env.JEST_WORKER_ID(13.7.3). --detectOpenHandlesusaasync_hookspara imprimir la pila de creación de cada handle pendiente cuando Jest avisa de que «no ha salido»: conexiones sin cerrar,setInterval, servidores escuchando, consumidores de cola. Suele señalar la línea culpable.
// "Se queda colgado, le pongo --forceExit y a otra cosa"
// package.json: "test:e2e": "jest --forceExit"
beforeAll(async () => {
const mod = await Test.createTestingModule({
imports: [AppModule],
}).compile();
app = mod.createNestApplication();
await app.init();
});
// No hay afterAll: la conexión del ORM y el pool siguen
// abiertos. --forceExit mata el proceso a la fuerza y
// ENMASCARA fugas reales que en producción agotan las
// conexiones del servidor de base de datos.beforeAll(async () => {
const mod = await Test.createTestingModule({
imports: [AppModule],
}).compile();
app = mod.createNestApplication();
await app.init();
orm = app.get(MikroORM);
});
afterAll(async () => {
await orm.close(true); // MikroOrmModule también cierra en
await app.close(); // onModuleDestroy; explicitarlo no molesta
});
// Sin --forceExit: si Jest no sale, hay un bug que arreglar.13.4 Tests unitarios de servicios
@nestjs/testing expone un constructor de módulos equivalente al de la aplicación real, pero sin servidor HTTP. El flujo es siempre el mismo: describes los providers, sustituyes lo que no quieres
ejecutar de verdad, llamas a .compile() —que resuelve el grafo de dependencias— y pides las instancias con .get(). Ten en cuenta que module.get(Token) falla con providers de
scope REQUEST o TRANSIENT, porque entonces no hay una única instancia: para esos usa await module.resolve(Token), y añade { strict: false } si el provider vive en un
submódulo que no lo exporta. Cerrar el módulo en afterEach con await module.close()
ejecuta los hooks de ciclo de vida y libera recursos.
13.4.1 Sustituir dependencias
| Mecanismo | Cuándo usarlo | Ejemplo |
|---|---|---|
useValue | Lo habitual: un objeto literal con jest.fn(); control total y aserciones sobre llamadas | { provide: MailService, useValue: { send: jest.fn() } } |
useClass | Cuando el doble tiene comportamiento reutilizable: un repositorio en memoria, un reloj fijo | { provide: Clock, useClass: FixedClock } |
useFactory | Cuando el doble depende de otro provider o de configuración | { provide: 'CFG', useFactory: () => ({ ttl: 0 }) } |
.overrideProvider() | Cuando importas un módulo completo (típico en e2e) y solo quieres cambiar una pieza | .overrideProvider(MailService).useValue(fake) |
.overrideGuard(), .overrideInterceptor(), .overrideFilter(), .overridePipe() | Atajos para sustituir enhancers declarados con decoradores o en un módulo | .overrideGuard(JwtAuthGuard).useValue({ canActivate: () => true }) |
override* van antes de .compile()
createTestingModule({...}).overrideProvider(X).useValue(y).compile(). Después de compilar, el grafo ya está resuelto y sustituir no afecta a las instancias creadas.
13.4.2 Mockear el repositorio y el EntityManager de MikroORM
@mikro-orm/nestjs registra un provider por entidad cuyo token se obtiene con getRepositoryToken(Entidad). Para un test unitario hay que sustituir ese token y, casi siempre, el
EntityManager, porque el patrón Unit of Work hace que toda escritura pase por él. Esta es la superficie mínima que suele hacer falta simular:
| Método | Qué debe hacer el doble |
|---|---|
find, findAll | Resolver a un array de entidades (por defecto, vacío) |
findOne | Resolver a la entidad o a null (nunca undefined) |
findOneOrFail | Resolver, o rechazar con NotFoundError |
findAndCount, count | Resolver a la tupla [entidades, total] y al número |
create | Devolver una instancia nueva sin tocar la base de datos |
assign | Mutar y devolver la misma entidad |
persist, remove | Devolver el propio EM: la API es fluent y se encadena |
flush, persistAndFlush, removeAndFlush | Resolver a void: aquí se ejecutaría el SQL real |
nativeDelete | Resolver al número de filas afectadas |
transactional | Invocar el callback pasándole un EM (ver aviso) |
getReference, fork, clear | Referencia por id; otro EM (en el doble, él mismo); vaciar la caché |
transactional como jest.fn() vacío
Si em.transactional devuelve undefined, el callback que contiene toda tu lógica de negocio nunca se ejecuta y el test pasa sin haber probado nada. El doble debe invocarlo: jest.fn(async (cb) => cb(em)).
import { EntityManager, EntityRepository } from '@mikro-orm/postgresql';
/** Convierte cada método de T en un jest.Mock, conservando el resto. */
export type Mocked<T> = { [K in keyof T]: T[K] extends (...a: never[]) => unknown ? jest.Mock : T[K] };
type Metodos = 'find' | 'findOne' | 'findOneOrFail' | 'findAndCount' | 'count' | 'create'
| 'assign' | 'persist' | 'remove' | 'persistAndFlush' | 'removeAndFlush' | 'flush'
| 'nativeDelete' | 'getReference' | 'transactional' | 'fork' | 'clear';
export type MockEm = Mocked<Pick<EntityManager, Metodos>>;
export function createMockEntityManager(): MockEm {
const em = {
find: jest.fn().mockResolvedValue([]),
findOne: jest.fn().mockResolvedValue(null),
findOneOrFail: jest.fn(),
findAndCount: jest.fn().mockResolvedValue([[], 0]),
count: jest.fn().mockResolvedValue(0),
create: jest.fn((_e: unknown, data: object) => ({ ...data })), // no toca la BD
assign: jest.fn((e: object, data: object) => Object.assign(e, data)),
flush: jest.fn().mockResolvedValue(undefined),
persistAndFlush: jest.fn().mockResolvedValue(undefined),
removeAndFlush: jest.fn().mockResolvedValue(undefined),
nativeDelete: jest.fn().mockResolvedValue(1),
getReference: jest.fn((_e: unknown, id: unknown) => ({ id })),
clear: jest.fn(),
} as unknown as MockEm;
em.persist = jest.fn(() => em); // fluent: em.persist(a).persist(b)
em.remove = jest.fn(() => em);
em.fork = jest.fn(() => em); // en un unitario basta con devolverse a sí mismo
// CLAVE: el callback transaccional TIENE que ejecutarse.
em.transactional = jest.fn(async (cb: (em: MockEm) => unknown) => cb(em));
return em;
}
/** Doble de repositorio: los mismos lectores, más el EM accesible desde él. */
export const createMockRepository = <T extends object>(em = createMockEntityManager()) => ({
find: em.find, findOne: em.findOne, findOneOrFail: em.findOneOrFail,
findAndCount: em.findAndCount, count: em.count, create: em.create,
findAll: jest.fn().mockResolvedValue([]),
getEntityManager: jest.fn(() => em), // repo.getEntityManager().flush()
}) as unknown as Mocked<EntityRepository<T>>;
// En el módulo de test, con el token que registra @mikro-orm/nestjs:
// { provide: getRepositoryToken(Task), useValue: createMockRepository<Task>(em) }
// { provide: EntityManager, useValue: em }@golevelup/ts-jest
Ofrece createMock<EntityManager>(), un proxy con todos los métodos como jest.fn() encadenables. Ahorra código y está muy extendida en proyectos Nest, pero como «responde a
todo», sigue siendo obligatorio dar comportamiento explícito a transactional y a cualquier método cuyo valor de retorno use tu lógica.
13.4.3 Ejemplo completo: un servicio con lógica no trivial
El cierre de un proyecto es un caso realista: varias reglas acumuladas, tres tipos de error distintos, un caso límite (proyecto sin tareas) y un efecto secundario que no debe deshacer la operación si falla.
@Injectable()
export class ProjectsService {
constructor(
private readonly em: EntityManager,
private readonly notifications: NotificationsService,
private readonly clock: Clock, // inyectado: nunca new Date() directo
) {}
/**
* Reglas: 1) debe existir; 2) solo el propietario cierra; 3) no puede quedar
* ninguna tarea sin terminar ni cancelar; 4) no se cierra dos veces;
* 5) se registra la fecha y se notifica a los miembros.
*/
async close(projectId: string, userId: string): Promise<Project> {
const project = await this.em.transactional(async (em) => {
const found = await em.findOne(Project, { id: projectId }, { populate: ['tasks'] });
if (!found) throw new NotFoundException(`Proyecto ${projectId} no encontrado`);
if (found.owner.id !== userId) {
throw new ForbiddenException('Solo el propietario puede cerrar el proyecto');
}
if (found.status === ProjectStatus.Closed) {
throw new BadRequestException('El proyecto ya está cerrado');
}
const pendientes = found.tasks.getItems()
.filter((t) => t.status !== TaskStatus.Done && t.status !== TaskStatus.Cancelled);
if (pendientes.length > 0) {
throw new BadRequestException(`Quedan ${pendientes.length} tareas sin cerrar: `
+ pendientes.map((t) => t.title).join(', '));
}
found.status = ProjectStatus.Closed;
found.closedAt = this.clock.now();
await em.flush();
return found;
});
// Fuera de la transacción a propósito: un fallo del correo
// no debe deshacer el cierre del proyecto.
await this.notifications.projectClosed(project);
return project;
}
}const AHORA = new Date('2026-03-15T10:00:00.000Z');
// Fábricas locales con valores por defecto: cada test declara SOLO lo que le
// importa, y ese delta documenta el caso (ver 13.12).
const unProyecto = (over: Partial<Project> = {}) => ({
id: 'p-1', name: 'Migración a Nest', status: ProjectStatus.Active, closedAt: null,
owner: { id: 'u-1' }, tasks: { getItems: () => [] }, ...over,
}) as unknown as Project;
const conTareas = (...estados: TaskStatus[]) => ({
getItems: () => estados.map((status, i) => ({ id: `t-${i}`, title: `Tarea ${i}`, status })),
}) as never;
describe('ProjectsService.close', () => {
let service: ProjectsService;
let em: MockEm;
let notifications: { projectClosed: jest.Mock };
beforeEach(async () => {
em = createMockEntityManager();
notifications = { projectClosed: jest.fn().mockResolvedValue(undefined) };
const module = await Test.createTestingModule({
providers: [ProjectsService,
{ provide: EntityManager, useValue: em },
{ provide: NotificationsService, useValue: notifications },
{ provide: Clock, useValue: { now: () => AHORA } }, // reloj fijo
],
}).compile();
service = module.get(ProjectsService);
});
afterEach(() => jest.clearAllMocks());
it('cierra un proyecto sin pendientes, fija la fecha y notifica', async () => {
const project = unProyecto({ tasks: conTareas(TaskStatus.Done, TaskStatus.Cancelled) });
em.findOne.mockResolvedValue(project);
const result = await service.close('p-1', 'u-1');
expect(result.status).toBe(ProjectStatus.Closed);
expect(result.closedAt).toEqual(AHORA);
expect(em.flush).toHaveBeenCalled();
expect(notifications.projectClosed).toHaveBeenCalledWith(project);
// Propiedad estructural: la escritura ocurre dentro de la transacción.
expect(em.transactional).toHaveBeenCalledTimes(1);
});
it('caso límite: un proyecto sin ninguna tarea se puede cerrar', async () => {
em.findOne.mockResolvedValue(unProyecto({ tasks: conTareas() }));
await expect(service.close('p-1', 'u-1'))
.resolves.toMatchObject({ status: ProjectStatus.Closed });
});
it('lanza NotFoundException y no escribe ni notifica', async () => {
em.findOne.mockResolvedValue(null);
// Tipo Y mensaje: el tipo fija el status HTTP, el mensaje es contrato.
await expect(service.close('p-404', 'u-1')).rejects.toThrow(NotFoundException);
await expect(service.close('p-404', 'u-1')).rejects.toThrow('Proyecto p-404 no encontrado');
expect(em.flush).not.toHaveBeenCalled();
expect(notifications.projectClosed).not.toHaveBeenCalled();
});
it('lanza ForbiddenException si quien cierra no es el propietario', async () => {
em.findOne.mockResolvedValue(unProyecto());
await expect(service.close('p-1', 'u-2')).rejects.toBeInstanceOf(ForbiddenException);
expect(em.flush).not.toHaveBeenCalled();
});
it('enumera las tareas pendientes, y no permite cerrar dos veces', async () => {
em.findOne.mockResolvedValue(unProyecto({
tasks: conTareas(TaskStatus.Done, TaskStatus.InProgress, TaskStatus.Todo) }));
await expect(service.close('p-1', 'u-1'))
.rejects.toThrow(/Quedan 2 tareas sin cerrar: Tarea 1, Tarea 2/);
em.findOne.mockResolvedValue(unProyecto({ status: ProjectStatus.Closed }));
await expect(service.close('p-1', 'u-1')).rejects.toThrow(BadRequestException);
});
it('si falla la notificación, el cierre YA está persistido', async () => {
em.findOne.mockResolvedValue(unProyecto({ tasks: conTareas(TaskStatus.Done) }));
notifications.projectClosed.mockRejectedValue(new Error('SMTP caído'));
// Documentar la decisión: el error se propaga pero el proyecto queda cerrado.
await expect(service.close('p-1', 'u-1')).rejects.toThrow('SMTP caído');
expect(em.flush).toHaveBeenCalled();
});
});it('falla si no existe', async () => {
em.findOne.mockResolvedValue(null);
// try/catch sin garantía de que se lanzara nada: si
// close() NO lanza, el test pasa igualmente.
try {
await service.close('x', 'u-1');
} catch (e) {
expect(e).toBeDefined(); // no afirma NADA
}
});
it('falla', () => {
// Sin await: la promesa rechazada se convierte en
// UnhandledPromiseRejection y el test pasa en verde.
expect(service.close('x', 'u-1')).rejects.toThrow();
});
it('error genérico', async () => {
// toThrow() sin argumento: vale cualquier error, incluido
// un TypeError provocado por un bug tuyo.
await expect(service.close('x', 'u-1')).rejects.toThrow();
});it('lanza NotFoundException con el id en el mensaje', async () => {
em.findOne.mockResolvedValue(null);
// await + tipo concreto + mensaje concreto.
await expect(service.close('x', 'u-1'))
.rejects.toThrow(NotFoundException);
await expect(service.close('x', 'u-1'))
.rejects.toThrow('Proyecto x no encontrado');
});
it('alternativa: inspeccionar la excepción completa', async () => {
em.findOne.mockResolvedValue(null);
// expect.assertions garantiza que el catch se ejecutó.
expect.assertions(3);
try {
await service.close('x', 'u-1');
} catch (e) {
expect(e).toBeInstanceOf(NotFoundException);
expect((e as NotFoundException).getStatus()).toBe(404);
expect((e as Error).message).toContain('no encontrado');
}
});BadRequestException (400, «el cliente se equivocó, no lo reintentes igual») con ConflictException (409, «estado incompatible») o con un
Error genérico (500, «he fallado yo, quizá reintenta») cambia el comportamiento del frontend, de los reintentos y de las alertas. Un test que solo mira el mensaje no protege ese contrato.
13.5 Guards, pipes, interceptores y filtros de forma aislada
Los enhancers concentran decisiones críticas —quién entra, qué datos se aceptan, qué se registra, cómo se convierte un error en una respuesta— en clases muy pequeñas y sin dependencias de negocio. Son, con diferencia, el
mejor retorno de inversión en tests unitarios: se instancian con new y se les pasa un contexto falso.
/** ExecutionContext falso: solo lo que consumen tus enhancers. */
export function mockExecutionContext(opts: {
user?: unknown; params?: object; query?: object; body?: unknown;
headers?: Record<string, string>; method?: string; url?: string;
handler?: () => void; controller?: Function;
} = {}) {
const req = {
user: opts.user, params: opts.params ?? {}, query: opts.query ?? {},
body: opts.body, headers: opts.headers ?? {},
method: opts.method ?? 'GET', url: opts.url ?? '/', route: { path: opts.url ?? '/' },
};
const res = { statusCode: 200, setHeader: jest.fn(), status: jest.fn().mockReturnThis(),
json: jest.fn().mockReturnThis() };
return {
switchToHttp: () => ({ getRequest: () => req, getResponse: () => res, getNext: () => jest.fn() }),
// getHandler y getClass son lo que lee el Reflector para los decoradores.
getHandler: () => opts.handler ?? function handler() {},
getClass: () => opts.controller ?? class TestController {},
getType: () => 'http', getArgs: () => [req, res], getArgByIndex: (i: number) => [req, res][i],
switchToRpc: jest.fn(), switchToWs: jest.fn(),
} as unknown as ExecutionContext & { __req: typeof req; __res: typeof res };
}
/** CallHandler falso: controla lo que "devuelve el controlador". */
export const mockCallHandler = (value: unknown = { ok: true }): CallHandler =>
({ handle: jest.fn(() => of(value)) });
export const failingCallHandler = (error: Error): CallHandler =>
({ handle: jest.fn(() => throwError(() => error)) });describe('RolesGuard', () => {
let guard: RolesGuard;
let reflector: { getAllAndOverride: jest.Mock };
beforeEach(() => {
// El Reflector es la única dependencia: mockearlo es trivial y hace
// explícito qué metadatos espera el guard.
reflector = { getAllAndOverride: jest.fn() };
guard = new RolesGuard(reflector as unknown as Reflector);
});
it('permite el paso si el endpoint no exige roles', () => {
reflector.getAllAndOverride.mockReturnValue(undefined);
expect(guard.canActivate(mockExecutionContext({ user: { roles: [] } }))).toBe(true);
});
it('permite el paso si el usuario tiene alguno de los roles exigidos', () => {
reflector.getAllAndOverride.mockReturnValue([Role.Admin, Role.Manager]);
const ctx = mockExecutionContext({ user: { id: 'u-1', roles: [Role.Manager] } });
expect(guard.canActivate(ctx)).toBe(true);
});
it.each([
['sin ninguno de los roles', { id: 'u-1', roles: [Role.User] }],
['sin roles', { id: 'u-1', roles: [] }],
['sin usuario (guard mal ordenado)', undefined],
])('deniega el paso con ForbiddenException: %s', (_caso, user) => {
reflector.getAllAndOverride.mockReturnValue([Role.Admin]);
// Denegar lanzando (y no devolviendo false) permite dar un mensaje útil.
expect(() => guard.canActivate(mockExecutionContext({ user })))
.toThrow(ForbiddenException);
});
it('lee los metadatos del handler Y de la clase, en ese orden', () => {
reflector.getAllAndOverride.mockReturnValue([Role.Admin]);
const handler = function borrar() {};
class ProjectsController {}
guard.canActivate(mockExecutionContext({ user: { roles: [Role.Admin] }, handler,
controller: ProjectsController }));
// Contrato real: @Roles() a nivel de método debe ganar al de clase.
expect(reflector.getAllAndOverride).toHaveBeenCalledWith(ROLES_KEY,
[handler, ProjectsController]);
});
});describe('ParseSlugPipe', () => {
const pipe = new ParseSlugPipe();
const meta = { type: 'param', data: 'slug' } as ArgumentMetadata;
it.each([['mi-proyecto', 'mi-proyecto'], [' Mi Proyecto ', 'mi-proyecto'],
['Año_2026', 'ano-2026']])('normaliza %s', (entrada, esperado) => {
expect(pipe.transform(entrada, meta)).toBe(esperado);
});
it.each(['', ' ', '---', '\u0000', 'a'.repeat(256), '../../etc/passwd'])
('rechaza %j con BadRequestException', (entrada) => {
// Los casos límite de un pipe son su razón de existir: son la frontera
// entre datos del exterior y tu dominio.
expect(() => pipe.transform(entrada, meta)).toThrow(BadRequestException);
});
});describe('TimeoutInterceptor', () => {
// Con temporizadores reales el test tardaría lo que el timeout; con
// temporizadores falsos avanzamos el reloj y tarda microsegundos.
beforeEach(() => jest.useFakeTimers());
afterEach(() => jest.useRealTimers());
it('deja pasar la respuesta si llega a tiempo', async () => {
const res$ = new TimeoutInterceptor(5000)
.intercept(mockExecutionContext(), mockCallHandler({ id: 't-1' }));
await expect(firstValueFrom(res$)).resolves.toEqual({ id: 't-1' });
});
it('convierte el timeout en RequestTimeoutException (408, no 500)', async () => {
const lento: CallHandler = { handle: () => timer(10_000).pipe(map(() => 'tarde')) };
const promesa = firstValueFrom(
new TimeoutInterceptor(5000).intercept(mockExecutionContext(), lento));
jest.advanceTimersByTime(5001);
await expect(promesa).rejects.toBeInstanceOf(RequestTimeoutException);
});
});
describe('DomainExceptionFilter', () => {
it('traduce un error de dominio a una respuesta HTTP estable', () => {
const ctx = mockExecutionContext({ url: '/projects/p-1/close' });
const host = { switchToHttp: ctx.switchToHttp } as unknown as ArgumentsHost;
new DomainExceptionFilter().catch(new TaskAlreadyDoneError('t-1'), host);
const res = ctx.switchToHttp().getResponse();
expect(res.status).toHaveBeenCalledWith(409);
expect(res.json).toHaveBeenCalledWith(expect.objectContaining({
statusCode: 409, code: 'TASK_ALREADY_DONE', path: '/projects/p-1/close',
}));
// El contrato de error también es contrato: si tu frontend enrama por
// "code", cambiarlo rompe clientes igual que cambiar una ruta.
});
});13.6 Tests de controladores: qué aportan realmente
Un controlador bien escrito no tiene lógica: valida por decoradores, delega en un servicio y devuelve. Un test unitario de esa clase acaba comprobando que controller.findOne(id) llama a
service.findOne(id), es decir, reescribiendo el cuerpo del método en forma de aserción. Cuesta mantener y no detecta ningún bug realista.
Lo verdaderamente interesante del controlador vive en los decoradores: la ruta, el método HTTP, el código de estado, la validación, los guards, la serialización. Y nada de eso se ejecuta cuando instancias la clase con
new: son metadatos que solo interpreta el runtime HTTP de Nest. Por eso, para la capa HTTP, un e2e con Supertest da mucha más información por línea de test.
Hay dos casos donde el test aislado de controlador sí paga: cuando el controlador tiene lógica que no se puede mover (elegir un DTO según cabeceras, componer varios servicios, mapear a distintos formatos), y cuando quieres verificar el mapeo de errores sin el coste de arrancar la aplicación. Puedes tener la capa HTTP real sin la base de datos real: arranca solo el controlador, sustituye el servicio y aplica los pipes globales.
describe('TasksController (HTTP aislado)', () => {
let app: INestApplication;
const service = { findAll: jest.fn(), create: jest.fn() };
beforeAll(async () => {
const mod = await Test.createTestingModule({
controllers: [TasksController],
providers: [{ provide: TasksService, useValue: service }],
})
// Sin base de datos ni JWT reales, pero con enrutado y validación reales.
.overrideGuard(JwtAuthGuard).useValue({ canActivate: (c: ExecutionContext) => {
c.switchToHttp().getRequest().user = { id: 'u-1', roles: [Role.User] };
return true;
} })
.compile();
app = mod.createNestApplication();
app.useGlobalPipes(new ValidationPipe({ whitelist: true, transform: true }));
await app.init();
});
afterAll(() => app.close());
it('convierte y valida la query: page=abc es 400', async () => {
await request(app.getHttpServer()).get('/tasks?page=abc').expect(400);
expect(service.findAll).not.toHaveBeenCalled();
});
it('aplica los valores por defecto del DTO y transforma tipos', async () => {
service.findAll.mockResolvedValue({ items: [], total: 0 });
await request(app.getHttpServer()).get('/tasks?page=2').expect(200);
// page llega como NUMBER 2, no como string: eso es lo que aporta
// este test y lo que un unitario con new TasksController() no ve.
expect(service.findAll).toHaveBeenCalledWith('u-1',
expect.objectContaining({ page: 2, limit: 20 }));
});
it('devuelve 201 y elimina propiedades no declaradas (whitelist)', async () => {
service.create.mockImplementation((_u, dto) => ({ id: 't-1', ...dto }));
const { body } = await request(app.getHttpServer()).post('/tasks')
.send({ title: 'Escribir tests', isAdmin: true }).expect(201);
expect(body).not.toHaveProperty('isAdmin');
});
});13.7 Tests de integración con base de datos real
13.7.1 Por qué los mocks de repositorio no bastan
Un mock devuelve lo que le dices que devuelva. Eso significa que valida tu suposición sobre la consulta, no la consulta. Estos fallos son invisibles para un unitario y aparecen todos en el primer despliegue: nombres de
columna o de propiedad mal escritos, operadores no soportados por el driver, un populate de una relación inexistente, restricciones de unicidad o claves ajenas, NOT NULL sin valor por defecto,
comportamiento de NULL en ordenación, colaciones y mayúsculas, tipos numeric que llegan como string, zonas horarias en timestamptz, migraciones divergentes del modelo y, muy
especialmente, el problema N+1, que un mock jamás mostrará porque no cuenta consultas.
13.7.2 SQLite en memoria, Testcontainers o base de datos dedicada
| Opción | Fidelidad | Velocidad | Complejidad en CI | Cuándo |
|---|---|---|---|---|
SQLite en memoria:memory: | Baja. Sin jsonb, sin uuid, sin ILIKE, sin ventanas, sin tipos enum, sin FOR UPDATE; claves ajenas desactivadas por defecto; tipado dinámico | Altísima: arranque en milisegundos, sin red | Nula: es una dependencia npm | Solo si tu acceso a datos es CRUD trivial. Prototipos y bibliotecas |
| PostgreSQL con Testcontainers | Máxima: el mismo motor y versión que producción | Media: 2–10 s de arranque del contenedor, reutilizable entre suites | Media: exige Docker en el runner (GitHub Actions lo tiene) | La opción por defecto para un proyecto serio; imprescindible si usas SQL específico de Postgres |
| Base de datos de test dedicada (servicio en CI o local) | Máxima si es la misma versión | Alta: ya está arrancada | Baja en CI (services:), pero exige que cada desarrollador la tenga y esté al día | Suites grandes donde el arranque del contenedor por proceso duele |
LIKE insensible a mayúsculas (SQLite lo es por defecto en ASCII) y en Postgres
LIKE distingue mayúsculas, así que el buscador no encuentra nada en producción. Falso positivo: el test falla al insertar jsonb y pierdes una tarde en un problema que no existe. Si aun así lo usas,
activa PRAGMA foreign_keys = ON para no ignorar la integridad referencial.
// globalSetup arranca UN contenedor para toda la ejecución y exporta la URL;
// cada worker de Jest se conecta a él con su propio ESQUEMA (ver 13.7.3).
export default async function globalSetup() {
const container = await new PostgreSqlContainer('postgres:16-alpine')
.withTmpFs({ '/var/lib/postgresql/data': 'rw' }) // datos en RAM: mucho más rápido
.withCommand(['postgres', '-c', 'fsync=off', '-c', 'full_page_writes=off'])
.start();
process.env.TEST_DB_URL = container.getConnectionUri();
(globalThis as { __PG__?: unknown }).__PG__ = container; // lo cierra globalTeardown
}
export async function createTestOrm(): Promise<MikroORM> {
const orm = await MikroORM.init({
...config,
clientUrl: process.env.TEST_DB_URL,
schema: `w${process.env.JEST_WORKER_ID ?? '1'}`, // aislamiento por worker
debug: process.env.SQL_DEBUG === '1' ? ['query', 'query-params'] : false,
allowGlobalContext: false, // fuerza em.fork() y detecta EM compartidos
});
await orm.schema.createSchema(); // el esquema del worker
await orm.schema.refreshDatabase(); // drop + create, rápido y determinista
return orm;
}refreshDatabase() frente a migraciones
refreshDatabase() genera el esquema desde las entidades: es rápido y siempre coherente con el modelo, ideal para el bucle de desarrollo. Pero no prueba tus migraciones, y una migración con un
NOT NULL sin valor por defecto sobre una tabla con datos revienta el despliegue aunque toda la suite
esté verde. La combinación sana: refreshDatabase() en local y en los tests, y un job de CI aparte que ejecute migration:up sobre una copia del esquema anterior más
migration:check para detectar divergencias entre modelo y migraciones.
13.7.3 Aislamiento entre tests
| Estrategia | Cómo | Ventajas e inconvenientes |
|---|---|---|
| Transacción con rollback | Abrir transacción en beforeEach, deshacerla en afterEach | Rapidísimo y perfecto. No sirve si el código bajo test gestiona sus propias transacciones o si la petición usa otra conexión (e2e) |
| Truncado de tablas | TRUNCATE ... RESTART IDENTITY CASCADE en beforeEach | Funciona siempre, incluso en e2e. Cuesta unos milisegundos y hay que respetar el orden o usar CASCADE |
refreshDatabase() por test | Recrear el esquema completo | Máxima garantía y lentitud (cientos de milisegundos): solo para tests de esquema o migraciones |
| Datos disjuntos | Cada test usa sus propios ids o su propio tenant | Sin limpieza y muy rápido, pero un test que hace find() sin filtro ve datos ajenos |
let em: EntityManager;
beforeAll(async () => {
orm = await createTestOrm();
em = orm.em as EntityManager; // EM GLOBAL compartido
});
// Sin limpieza: el test 2 ve los datos del test 1, y el
// resultado depende del ORDEN de ejecución. Con --shard
// o con .only el mismo test falla sin haber cambiado.
it('actualiza el título', async () => {
const t = await em.findOne(Task, { id });
t!.title = 'Nuevo';
await em.flush();
// La entidad sigue en la Identity Map: este findOne NO
// consulta la base de datos, devuelve el objeto de
// memoria. El test pasa aunque el UPDATE no llegara.
const leido = await em.findOne(Task, { id });
expect(leido!.title) .toBe('Nuevo'); // no prueba nada
});let orm: MikroORM;
let em: EntityManager;
beforeAll(async () => { orm = await createTestOrm(); });
afterAll(async () => { await orm.close(true); });
beforeEach(async () => {
await truncateAll(orm); // estado conocido
em = orm.em.fork(); // EM limpio, sin caché heredada
});
it('actualiza el título', async () => {
const t = await em.findOneOrFail(Task, { id });
t.title = 'Nuevo';
await em.flush();
em.clear(); // vacía la Identity Map
const leido = await em.findOneOrFail(Task, { id });
expect(leido.title).toBe('Nuevo'); // ahora sí lee de la BD
});
// Genérico y ordenado: una sola sentencia, sin listas a mano.
export async function truncateAll(orm: MikroORM) {
const tablas = orm.getMetadata().getAll();
const nombres = Object.values(tablas)
.filter((m) => !m.abstract && !m.pivotTable && m.tableName)
.map((m) => `"${m.schema ?? 'public'}"."${m.tableName}"`);
await orm.em.getConnection()
.execute(`TRUNCATE ${nombres.join(', ')} RESTART IDENTITY CASCADE`);
}EntityManager cachea por identidad: si pides dos veces la misma entidad en el mismo EM, la segunda vez recibes el mismo objeto de memoria sin ir a la base de datos. Por eso un test puede pasar con
un flush() que en realidad no persistió lo que creías. Regla: em.clear() (o un em.fork() nuevo) entre la escritura y la lectura de comprobación. Y en producción,
allowGlobalContext: false más el middleware de contexto de @mikro-orm/nestjs evitan compartir EM entre peticiones.
describe('TasksRepository (integración)', () => {
let orm: MikroORM; let em: EntityManager; let repo: TasksRepository;
let project: Project;
beforeAll(async () => { orm = await createTestOrm(); });
afterAll(async () => { await orm.close(true); });
beforeEach(async () => {
await truncateAll(orm);
em = orm.em.fork();
repo = em.getRepository(Task) as TasksRepository;
// Datos con factorías (13.12): explícitos y mínimos.
const owner = em.create(User, userFactory({ email: 'ana@example.com' }));
project = em.create(Project, projectFactory({ owner, name: 'Migración' }));
em.create(Task, taskFactory({ project, title: 'Diseñar esquema',
status: TaskStatus.Done, tags: ['db'], dueDate: new Date('2026-01-10') }));
em.create(Task, taskFactory({ project, title: 'Escribir tests',
status: TaskStatus.Todo, tags: ['db', 'qa'], dueDate: new Date('2026-02-20') }));
em.create(Task, taskFactory({ project, title: 'Revisar PR',
status: TaskStatus.Todo, tags: [], dueDate: null }));
await em.flush();
em.clear();
});
it('filtra por estado y etiqueta, ordena y pagina', async () => {
const [items, total] = await repo.search(
{ projectId: project.id, status: TaskStatus.Todo, tag: 'db' },
{ page: 1, limit: 10, orderBy: 'dueDate', dir: QueryOrder.ASC });
expect(total).toBe(1);
expect(items[0].title).toBe('Escribir tests');
});
it('ordena por dueDate dejando los NULL al final (contrato explícito)', async () => {
const [items] = await repo.search({ projectId: project.id },
{ page: 1, limit: 10, orderBy: 'dueDate', dir: QueryOrder.ASC_NULLS_LAST });
// Sin NULLS LAST, PostgreSQL pone los NULL primero en ASC y SQLite al
// final: exactamente el tipo de diferencia que solo ve un test real.
expect(items.map((t) => t.title))
.toEqual(['Diseñar esquema', 'Escribir tests', 'Revisar PR']);
});
it('carga la relación sin N+1: una sola consulta con populate', async () => {
const consultas: string[] = [];
// El logger del ORM es la forma fiable de contar consultas.
orm.config.set('logger', (msg: string) => consultas.push(msg));
orm.config.set('debug', ['query']);
const tareas = await em.fork().find(Task, {}, { populate: ['project.owner'] });
expect(tareas).toHaveLength(3);
expect(tareas[0].project.owner.email).toBe('ana@example.com');
// 3 tareas cargadas con 2 consultas (tasks + join de project/owner),
// no con 1 + 3 + 3. Este test se rompe el día que alguien quita el populate.
expect(consultas.filter((q) => q.includes('select')).length).toBeLessThanOrEqual(3);
orm.config.set('debug', false);
});
it('respeta la restricción de unicidad (título único por proyecto)', async () => {
const otro = em.fork();
otro.create(Task, taskFactory({ project: otro.getReference(Project, project.id),
title: 'Revisar PR' }));
// UniqueConstraintViolationException, no un Error genérico: el servicio
// puede capturarla y devolver 409 en lugar de 500.
await expect(otro.flush()).rejects.toBeInstanceOf(UniqueConstraintViolationException);
});
it('borra en cascada las tareas al borrar el proyecto', async () => {
const em2 = orm.em.fork();
await em2.removeAndFlush(await em2.findOneOrFail(Project, { id: project.id }));
await expect(orm.em.fork().count(Task, {})).resolves.toBe(0);
});
});13.7.4 Testcontainers: bases de datos efímeras de verdad
Testcontainers es una biblioteca que arranca contenedores Docker desde el propio proceso de test y los destruye al terminar. La idea es sencilla y su consecuencia enorme: la infraestructura pasa a ser una dependencia del
test, no del entorno. Ya no hay que documentar en el README «instala PostgreSQL 16 y crea la base de datos taskflow_test»; ya no hay una máquina con la versión 14 y otra con la 16; ya no hay un test que falla
solo en el portátil de quien se incorporó ayer. El contenedor se define en el código, se versiona con él y se levanta igual en local que en el runner de CI.
El mecanismo interno merece conocerse porque explica sus rarezas. Testcontainers habla con el socket de Docker, crea el contenedor con un puerto aleatorio mapeado al 5432 —de ahí que siempre haya que preguntar
por la URL con getConnectionUri() en lugar de asumir localhost:5432—, espera a una estrategia de espera (por defecto, para el módulo de PostgreSQL, a que el registro contenga
database system is ready to accept connections) y registra el contenedor en un contenedor auxiliar llamado Ryuk, cuya única misión es matar todo lo que quede huérfano si el proceso de test muere de forma
abrupta. Sin Ryuk, un Ctrl+C a destiempo te dejaría contenedores zombis consumiendo memoria durante días.
Las tres formas de compartir el contenedor
| Estrategia | Cómo funciona | Coste típico | Cuándo |
|---|---|---|---|
Uno por archivobeforeAll en cada .spec.ts | Cada proceso arranca y destruye el suyo | 2–10 s × número de archivos | Un proyecto con dos o tres archivos de integración; o un test que necesita una versión distinta del motor |
Uno por ejecuciónglobalSetup / globalTeardown | Jest ejecuta ese archivo una sola vez, antes de crear los workers; la URL se pasa por process.env | 2–10 s en total | La opción por defecto. Combínala con un esquema por worker para el aislamiento |
Reutilizado entre ejecuciones.withReuse() | Testcontainers calcula un hash de la configuración; si ya existe un contenedor con esa etiqueta, se engancha a él en lugar de crear otro | ~0 s a partir de la segunda ejecución | Bucle de desarrollo local, donde se lanza la suite decenas de veces al día. Nunca en CI |
import { PostgreSqlContainer, StartedPostgreSqlContainer } from '@testcontainers/postgresql';
// Jest ejecuta globalSetup UNA vez por ejecución, en el proceso padre, antes de
// crear ningún worker. Es el único punto donde "una vez" significa de verdad
// una vez: todo lo que esté en un beforeAll ocurre una vez POR ARCHIVO.
export default async function globalSetup(): Promise<void> {
let contenedor = new PostgreSqlContainer('postgres:16-alpine')
.withDatabase('taskflow_test').withUsername('test').withPassword('test')
// Datos en RAM y durabilidad desactivada: NO queremos que sobrevivan a un
// corte de luz, queremos que los INSERT vayan rápido. En producción esto
// sería negligencia; aquí es la optimización que más tiempo ahorra.
.withTmpFs({ '/var/lib/postgresql/data': 'rw,noexec,nosuid,size=1024m' })
.withCommand(['postgres', '-c', 'fsync=off', '-c', 'full_page_writes=off',
'-c', 'synchronous_commit=off', '-c', 'max_connections=200']);
// REUTILIZACIÓN: solo en local. Exige testcontainers.reuse.enable=true en
// ~/.testcontainers.properties. En CI el runner es efímero: reutilizar no
// ahorra nada y arriesga arrastrar estado de otra rama.
if (!process.env.CI) contenedor = contenedor.withReuse();
const iniciado: StartedPostgreSqlContainer = await contenedor.start();
process.env.TEST_DB_URL = iniciado.getConnectionUri();
// globalSetup y globalTeardown se ejecutan en el MISMO contexto, así que
// globalThis es la forma soportada de pasarse el objeto entre ambos.
(globalThis as Record<string, unknown>).__PG_CONTAINER__ = iniciado;
// Migraciones una sola vez sobre la plantilla (ver 13.7.5).
await prepararPlantilla(iniciado.getConnectionUri());
}export default async function globalTeardown(): Promise<void> {
const c = (globalThis as Record<string, unknown>).__PG_CONTAINER__ as
{ stop: (o?: object) => Promise<void> } | undefined;
// Con withReuse() el contenedor NO debe pararse: se deja vivo a propósito
// para la siguiente ejecución. Ryuk lo recogerá cuando expire.
if (c && process.env.CI) await c.stop({ timeout: 5000 });
}
// jest.integration.config.ts
// globalSetup: '<rootDir>/test/setup/global-setup.ts',
// globalTeardown: '<rootDir>/test/setup/global-teardown.ts',
// testRegex: '.*\\.int-spec\\.ts$',
// maxWorkers: '50%', // cada worker tendrá su propia base de datosCould not find a valid Docker environment
Testcontainers necesita un daemon de Docker accesible. En GitHub Actions con runs-on: ubuntu-latest viene de serie, pero dentro de un contenedor —o en runners propios con Podman, Colima o Docker
Desktop en macOS— hay que apuntar DOCKER_HOST al socket correcto y, en algunos casos, definir TESTCONTAINERS_HOST_OVERRIDE. Si tu CI no puede darte Docker, usa un servicio de PostgreSQL declarado
en el workflow (13.18) y deja Testcontainers para el desarrollo local: la clave es que createTestOrm() lea la URL de una variable de entorno, de modo que el origen del motor sea un detalle intercambiable
y ningún test tenga que saber de dónde salió su base de datos.
13.7.5 Estrategias de limpieza: velocidad frente a aislamiento
La tabla de 13.7.3 presentaba las cuatro estrategias; conviene ahora entender por qué cada una cuesta lo que cuesta, porque en una suite de trescientos tests de integración la diferencia entre la mejor y la peor son varios minutos por ejecución, multiplicados por cada push de cada persona del equipo.
| Estrategia | Coste por test | Aislamiento | Limitación decisiva |
|---|---|---|---|
| Transacción con reversión | ~1 ms. ROLLBACK descarta el registro de deshacer; no toca las tablas | Total dentro del proceso | Solo funciona si todo el código bajo test comparte esa conexión. Un e2e por HTTP toma otra del pool y no ve nada |
TRUNCATE de todas las tablas | 5–30 ms según número de tablas: es DDL, exige bloqueo exclusivo y reescribe el fichero | Total, también entre conexiones | Requiere CASCADE o el orden topológico correcto; y borra los datos de referencia, que hay que resembrar |
DELETE FROM por tabla | Más lento que TRUNCATE con muchas filas, más rápido con muy pocas y sin bloqueo exclusivo | Total | No reinicia las secuencias; deja las tablas infladas si no hay autovacuum |
| Base de datos por test desde plantilla | 50–200 ms. CREATE DATABASE ... TEMPLATE copia ficheros a nivel de sistema | Absoluto: procesos distintos, catálogos distintos | Cuesta un orden de magnitud más; reserva para tests de migraciones o de multitenencia |
Recrear el esquema (refreshDatabase) | 200–800 ms: ejecuta todo el DDL del modelo | Absoluto | Inviable por test; correcto una vez por archivo o por worker |
// Patrón: el test corre DENTRO de una transacción que nunca se confirma.
// Es la estrategia más rápida que existe y la que produce tests más limpios,
// siempre que el código bajo test no gestione sus propias transacciones.
export function useTransactionalEm(getOrm: () => MikroORM) {
let em: EntityManager;
beforeEach(async () => {
em = getOrm().em.fork();
await em.begin(); // BEGIN explícito sobre la conexión de ESTE fork
});
afterEach(async () => {
// Si el test dejó la transacción marcada como fallida, rollback igualmente.
if (em.isInTransaction()) await em.rollback();
em.clear();
});
return () => em;
}
// Uso:
describe('TasksService (integración, rollback por test)', () => {
const em = useTransactionalEm(() => orm);
it('no deja rastro entre tests', async () => {
em().create(Task, taskFactory({ title: 'Efímera' }));
await em().flush(); // INSERT real, dentro de la transacción abierta
await expect(em().count(Task, {})).resolves.toBe(1);
});
it('empieza con la tabla vacía', async () => {
await expect(em().count(Task, {})).resolves.toBe(0); // el rollback ya ocurrió
});
});em.transactional(), MikroORM abre una transacción anidada
mediante SAVEPOINT. Eso funciona, pero si tu código hace commit explícito sobre el EM raíz, la reversión posterior no deshará nada y contaminarás los tests siguientes con un fallo que aparecerá en
otro archivo. Y en un e2e la reversión es directamente inútil: la petición HTTP se atiende con un em.fork() distinto, que toma otra conexión del pool y jamás verá los datos de tu transacción abierta —el
test insertará un usuario, la petición devolverá 401 porque ese usuario «no existe» y perderás una tarde—. Regla operativa: reversión para integración de servicios y repositorios; truncado para todo lo que pase por HTTP.
// PostgreSQL permite clonar una base de datos entera copiando ficheros:
// CREATE DATABASE destino TEMPLATE origen;
// Es mucho más rápido que volver a ejecutar el DDL o las migraciones, y da
// aislamiento perfecto: cada worker (o cada test caro) tiene su propia base.
export async function prepararPlantilla(url: string): Promise<void> {
const orm = await MikroORM.init({ ...config, clientUrl: url });
await orm.migrator.up(); // las migraciones REALES, una sola vez
await orm.seeder.seed(DatosDeReferenciaSeeder); // países, roles, planes...
await orm.close(true);
}
export async function clonarPlantilla(nombre: string): Promise<string> {
const admin = new Client({ connectionString: process.env.TEST_DB_URL });
await admin.connect();
// La plantilla no puede tener conexiones abiertas mientras se clona: por eso
// prepararPlantilla() cierra el ORM antes de terminar.
await admin.query(`DROP DATABASE IF EXISTS "${nombre}"`);
await admin.query(`CREATE DATABASE "${nombre}" TEMPLATE "taskflow_test"`);
await admin.end();
return process.env.TEST_DB_URL!.replace(/\/[^/]+$/, `/${nombre}`);
}
// En cada worker: const url = await clonarPlantilla(`w${process.env.JEST_WORKER_ID}`);
// Ventaja añadida: al clonar una plantilla migrada, la suite verifica en cada
// ejecución que las migraciones se aplican sin errores. refreshDatabase() no.13.7.6 Por qué SQLite en memoria no sustituye a PostgreSQL
La tentación es comprensible: SQLite arranca en milisegundos, no necesita Docker y MikroORM lo soporta con cambiar el driver. El razonamiento implícito es «SQL es SQL». No lo es. SQLite es un motor empotrado, diseñado para un fichero local y un único escritor; PostgreSQL es un motor cliente-servidor con control de concurrencia multiversión. No comparten dialecto, ni sistema de tipos, ni semántica de bloqueos. Un test es un experimento controlado, y su valor depende por completo de que la variable que no controlas sea la misma que en producción. Cambiar el motor destruye esa premisa.
| Dimensión | PostgreSQL | SQLite | Consecuencia práctica en tu suite |
|---|---|---|---|
| Sistema de tipos | Estricto y estático: uuid, jsonb, numeric, timestamptz, text[], tipos enum propios | Afinidad dinámica: cinco clases de almacenamiento; un varchar(10) acepta un texto de 5000 caracteres | El test acepta datos que producción rechaza. Un enum con un valor inválido pasa en verde y falla en el despliegue |
| Dialecto | ILIKE, DISTINCT ON, funciones de ventana completas, RETURNING avanzado, operadores ->> y @> de JSON, array_agg, tsvector | Nada de ILIKE (LIKE ya ignora mayúsculas en ASCII), sin tipos array nativos, JSON limitado, sin tsvector | Toda consulta específica de Postgres es intestable, así que se acaba escribiendo SQL «de mínimo común denominador»: el motor de test dicta el diseño de producción |
| Concurrencia | MVCC: lectores y escritores no se bloquean; SELECT ... FOR UPDATE, niveles de aislamiento reales, detección de interbloqueos | Bloqueo a nivel de toda la base de datos; un único escritor; SQLITE_BUSY en lugar de esperar | Es imposible testear un bloqueo pesimista, una condición de carrera o un interbloqueo, que son justo los bugs que no se detectan leyendo el código |
| Restricciones | Claves ajenas siempre activas; DEFERRABLE; restricciones CHECK y de exclusión | Claves ajenas desactivadas por defecto (PRAGMA foreign_keys) | Un borrado que en producción viola una clave ajena, en el test funciona: el bug llega a producción con la suite en verde |
| Fechas y zonas | timestamptz normaliza a UTC y conserva el instante; aritmética con interval | Guarda texto o número; sin tipo fecha; sin noción de zona | Los bugs de zona horaria —los más caros de diagnosticar— son invisibles en el test |
| Ordenación y colación | Depende de la colación de la base de datos; NULL primero en ASC | Comparación binaria; NULL al final en ASC | Un test de paginación ordenada pasa con un orden y producción devuelve otro: paginación con elementos duplicados o perdidos |
| Errores | SQLSTATE normalizado: 23505 unicidad, 23503 clave ajena, 40001 serialización | Códigos propios y mensajes distintos | El catch que traduce «violación de unicidad» a 409 nunca se ejercita de verdad |
Dicho esto, hay un uso legítimo y acotado: una biblioteca genuinamente multi-motor, cuyo compromiso público sea funcionar en varios dialectos, debería testear en todos ellos, SQLite incluido. Y un prototipo de fin de
semana no necesita Docker. Lo que no es defendible es una aplicación que en producción habla PostgreSQL y cuya suite entera habla SQLite: eso no es una suite de tests, es un simulacro que produce confianza sin producir
información. Si por restricciones del entorno no te queda otra, al menos activa PRAGMA foreign_keys = ON, prohíbe el SQL específico de Postgres mediante revisión de código y mantén una suite reducida de
smoke tests contra PostgreSQL real en CI: es un mal menor consciente, no una decisión de arquitectura.
13.8 Testear código transaccional y concurrente
Hay una categoría de bugs que ningún test unitario detecta y que ningún code review ve con fiabilidad: los que solo aparecen cuando dos cosas ocurren a la vez, o cuando algo falla justo a mitad. Son los más caros del oficio, porque no producen una excepción visible sino datos incorrectos que nadie descubre hasta meses después: un saldo descuadrado, un pedido cobrado dos veces, un contador que perdió incrementos. Testearlos exige una base de datos real (13.7) y un poco de astucia para provocar a propósito lo que en producción ocurre por azar.
13.8.1 Comprobar que una operación es realmente atómica
La atomicidad no se comprueba mirando si hay un @Transactional() o un em.transactional() en el código: eso es verificar la implementación. Se comprueba con la definición: si la operación falla a
mitad, el estado observable debe ser idéntico al de antes de empezar. El test tiene por tanto tres fases: fotografiar el estado, forzar el fallo en el punto más incómodo posible y verificar que la fotografía sigue siendo
válida. Y hay un detalle que se olvida siempre: la verificación debe hacerse desde otro EntityManager, porque el que ejecutó la operación tiene su Identity Map contaminada con objetos en memoria que
nunca llegaron a la base de datos.
it('es transaccional', async () => {
const em = createMockEntityManager();
await service.transferir('a', 'b', 100);
// Comprueba que se LLAMÓ a transactional. Es decir,
// comprueba que existe una línea concreta de código.
expect(em.transactional).toHaveBeenCalled();
// No prueba la atomicidad: si dentro del callback hay
// un em.flush() intermedio seguido de una llamada HTTP
// que falla, o si alguien captura la excepción con un
// try/catch silencioso, la transacción se confirma a
// medias y este test SIGUE EN VERDE.
});
it('deja el saldo bien', async () => {
await service.transferir('a', 'b', 100);
// Solo el camino feliz. El 100% de los bugs de
// atomicidad viven en el camino que este test no pisa.
expect((await em.findOne(Cuenta, 'a'))!.saldo).toBe(900);
});it('revierte TODO si falla el último paso', async () => {
const antes = await instantanea(orm); // estado inicial
// El fallo se inyecta en una dependencia REAL del flujo,
// en el punto más tardío posible: cuando ya hay tres
// escrituras hechas dentro de la transacción.
ledger.registrar.mockRejectedValueOnce(
new Error('ledger no disponible'));
await expect(service.transferir('a', 'b', 100))
.rejects.toThrow('ledger no disponible');
// Verificación desde OTRO EntityManager: sin Identity Map
// contaminada, leyendo filas de verdad.
expect(await instantanea(orm)).toEqual(antes);
});
async function instantanea(orm: MikroORM) {
const em = orm.em.fork(); // fork limpio, sin caché
const cuentas = await em.find(Cuenta, {}, { orderBy: { id: 'ASC' } });
const movs = await em.count(Movimiento, {});
return { saldos: cuentas.map((c) => [c.id, c.saldo]), movs };
}13.8.2 Simular un fallo a mitad de transacción
Para que el test sea honesto, el fallo debe ocurrir después de las primeras escrituras. Si lo inyectas al principio no pruebas nada: no había nada que revertir. Hay cuatro puntos de inyección, ordenados de menos a más realista.
| Técnica | Qué simula | Cómo |
|---|---|---|
| Doble de un colaborador | Un servicio externo que falla: pasarela, cola, otro microservicio | mockRejectedValueOnce() en el colaborador que se invoca al final |
| Violación de restricción real | Un choque de unicidad o de clave ajena que solo la base de datos conoce | Preparar el dato conflictivo antes y dejar que el flush reviente de verdad |
Spy parcial sobre el EntityManager | Un fallo de red o del driver en la enésima escritura | jest.spyOn(em, 'flush') que delega en el real y falla en la segunda llamada |
| Sentencia que aborta | Un timeout de sentencia o una cancelación por parte del servidor | SET LOCAL statement_timeout = '50ms' más una consulta lenta, o pg_terminate_backend |
describe('TransfersService.transferir (atomicidad)', () => {
let orm: MikroORM; let service: TransfersService; let ledger: { registrar: jest.Mock };
beforeEach(async () => {
await truncateAll(orm);
const em = orm.em.fork();
em.create(Cuenta, { id: 'a', saldo: 1000 });
em.create(Cuenta, { id: 'b', saldo: 0 });
await em.flush();
});
it('el fallo del ledger no deja movimientos ni saldos a medias', async () => {
ledger.registrar.mockRejectedValueOnce(new Error('ledger no disponible'));
await expect(service.transferir('a', 'b', 100)).rejects.toThrow();
const em = orm.em.fork();
// Las TRES afirmaciones importan: origen intacto, destino intacto y
// ningún movimiento huérfano. Un rollback parcial fallaría solo en una.
expect((await em.findOneOrFail(Cuenta, 'a')).saldo).toBe(1000);
expect((await em.findOneOrFail(Cuenta, 'b')).saldo).toBe(0);
await expect(em.count(Movimiento, {})).resolves.toBe(0);
});
it('un fallo del driver en el segundo flush revierte el primero', async () => {
const em = orm.em.fork();
const real = em.flush.bind(em);
let n = 0;
// Spy PARCIAL: la primera escritura ocurre de verdad (así hay algo que
// revertir) y la segunda simula una caída de la conexión.
jest.spyOn(em, 'flush').mockImplementation(async () => {
if (++n === 2) throw new Error('Connection terminated unexpectedly');
return real();
});
await expect(service.transferirCon(em, 'a', 'b', 100)).rejects.toThrow();
await expect(orm.em.fork().count(Movimiento, {})).resolves.toBe(0);
});
it('la transacción sobrevive a un statement_timeout como error, no como cuelgue', async () => {
const em = orm.em.fork();
await em.begin();
await em.getConnection().execute("SET LOCAL statement_timeout = '50ms'");
// Verificamos el contrato de errores: un timeout debe llegar al servicio
// como excepción capturable, no dejar la conexión en estado indefinido.
await expect(em.getConnection().execute('SELECT pg_sleep(1)')).rejects.toThrow();
await em.rollback();
expect(em.isInTransaction()).toBe(false);
});
});13.8.3 Bloqueo optimista: provocar un conflicto de verdad
El bloqueo optimista parte de una apuesta: los conflictos son raros, así que en lugar de bloquear la fila se añade una columna de versión y, al escribir, se comprueba que nadie la haya cambiado mientras tanto. MikroORM lo
implementa con @Property({ version: true }): el UPDATE generado incluye WHERE id = ? AND version = ?, y si afecta a cero filas lanza OptimisticLockError. El bug que hay que
cazar no es que el mecanismo funcione —eso lo garantiza el ORM— sino que tu servicio reaccione bien al conflicto: unos casos deben devolver 409 al cliente y otros deben reintentar de forma transparente.
La clave del test es que dos EntityManager distintos lean la misma versión antes de que ninguno escriba. Con un único EM es imposible: la Identity Map devolvería el mismo objeto y la versión se
actualizaría sola. Por eso el patrón es siempre fork(), fork(), leer en los dos, escribir en el primero y luego en el segundo.
// Entidad: @Property({ version: true }) version!: number;
describe('Bloqueo optimista sobre Task', () => {
it('la segunda escritura concurrente falla con OptimisticLockError', async () => {
const id = (await crearTarea(orm, { title: 'Original' })).id;
// DOS contextos independientes = dos conexiones y dos Identity Map.
const emA = orm.em.fork(); const emB = orm.em.fork();
const a = await emA.findOneOrFail(Task, id);
const b = await emB.findOneOrFail(Task, id);
expect(a.version).toBe(b.version); // ambos leyeron la versión 1
a.title = 'Cambio de Ana';
await emA.flush(); // UPDATE ... WHERE version = 1 → ok, ahora 2
b.title = 'Cambio de Bruno';
// El UPDATE de B lleva WHERE version = 1 y afecta a 0 filas: el ORM lo
// detecta y lanza. Sin versión, este UPDATE habría machacado el cambio de
// Ana en silencio: la actualización perdida, el bug invisible por excelencia.
await expect(emB.flush()).rejects.toBeInstanceOf(OptimisticLockError);
expect((await orm.em.fork().findOneOrFail(Task, id)).title).toBe('Cambio de Ana');
});
it('el servicio traduce el conflicto a 409 y no a 500', async () => {
// Un OptimisticLockError sin capturar sale como 500: el cliente creería
// que el servidor está roto y reintentaría con la misma versión caducada.
await expect(simularConflicto(orm, service)).rejects.toBeInstanceOf(ConflictException);
});
it('reintenta automáticamente y converge: dos incrementos, ninguno perdido', async () => {
const id = (await crearContador(orm, { valor: 0 })).id;
// Promise.all lanza ambas operaciones sobre el mismo bucle de eventos:
// se solapan de verdad, no es una simulación.
await Promise.all([service.incrementar(id), service.incrementar(id)]);
// Con reintento sobre OptimisticLockError el resultado es 2. Sin él, uno
// de los dos incrementos se pierde y el test devuelve 1: exactamente el
// fallo que en producción descuadra un inventario o un saldo.
expect((await orm.em.fork().findOneOrFail(Contador, id)).valor).toBe(2);
});
it('con bloqueo pesimista, la segunda transacción espera en lugar de fallar', async () => {
// PESSIMISTIC_WRITE emite SELECT ... FOR UPDATE: la fila queda bloqueada
// hasta el commit. Es la alternativa cuando el conflicto es FRECUENTE y
// reintentar sale más caro que esperar. Imposible de testear en SQLite.
const id = (await crearContador(orm, { valor: 0 })).id;
await orm.em.fork().transactional(async (em) => {
await em.findOneOrFail(Contador, id, { lockMode: LockMode.PESSIMISTIC_WRITE });
const otro = orm.em.fork();
// El segundo lector se queda esperando; con NOWAIT falla de inmediato,
// que es lo que permite afirmarlo en un test sin colgarlo para siempre.
await expect(otro.findOne(Contador, id,
{ lockMode: LockMode.PESSIMISTIC_WRITE_OR_FAIL })).rejects.toThrow();
});
});
});40P01. Es un fallo esperable en
un sistema con concurrencia, no un desastre, y la respuesta correcta es reintentar. Un test que abre dos transacciones bloqueando las filas A, B y B, A respectivamente demuestra dos cosas: que tu
código traduce ese error a un reintento y no a un 500, y que el orden canónico de bloqueo que documentaste en el servicio se respeta. Ordenar siempre los identificadores antes de bloquear elimina la clase
entera de bugs; el test es lo que impide que alguien lo rompa dentro de seis meses.
13.8.4 Testear un trabajo en cola
Una cola introduce una frontera asíncrona: quien publica no espera al resultado. Eso complica el test porque la aserción clásica —llamar y comprobar— ya no vale: hay que comprobar dos cosas por separado y en dos niveles distintos. Primero, que el productor encola el trabajo correcto, con el nombre, la carga útil y las opciones esperadas. Segundo, que el consumidor procesa correctamente ese trabajo. Solo el tercer test, el de integración, junta ambos extremos, y es el único que necesita un Redis real.
// NIVEL 1 · Productor. El doble de la cola es trivial y la aserción es el
// CONTRATO del mensaje: quien lo consuma dependerá de esa forma exacta.
describe('ReportsService (productor)', () => {
const queue = { add: jest.fn().mockResolvedValue({ id: 'job-1' }) };
it('encola la generación con idempotencia y reintentos', async () => {
await service.solicitarInforme('p-1', 'u-1');
expect(queue.add).toHaveBeenCalledWith('generar-informe',
{ projectId: 'p-1', userId: 'u-1' },
expect.objectContaining({
// jobId estable: si el usuario pulsa el botón tres veces, BullMQ
// deduplica. Sin esto se generan tres informes y se cobran tres veces.
jobId: 'informe:p-1:u-1',
attempts: 3, backoff: { type: 'exponential', delay: 1000 },
removeOnComplete: 100, removeOnFail: 500,
}));
});
});
// NIVEL 2 · Consumidor. Es una clase normal: se prueba invocando su método
// con un Job falso. No hace falta Redis para verificar la LÓGICA.
describe('ReportProcessor (consumidor)', () => {
const job = (data: object, over: object = {}) =>
({ id: 'job-1', name: 'generar-informe', data, attemptsMade: 0,
updateProgress: jest.fn(), log: jest.fn(), ...over }) as unknown as Job;
it('genera el informe y notifica al usuario', async () => {
const res = await processor.process(job({ projectId: 'p-1', userId: 'u-1' }));
expect(res).toMatchObject({ url: expect.stringContaining('p-1') });
expect(mailer.enviar).toHaveBeenCalledTimes(1);
});
it('es idempotente: reprocesar un trabajo no duplica el informe', async () => {
// Un worker puede morir tras terminar y antes de confirmar: BullMQ
// reentrega. La entrega es "al menos una vez", NUNCA "exactamente una".
// Si tu consumidor no es idempotente, tienes un bug latente garantizado.
await processor.process(job({ projectId: 'p-1', userId: 'u-1' }));
await processor.process(job({ projectId: 'p-1', userId: 'u-1' }));
await expect(orm.em.fork().count(Informe, { project: 'p-1' })).resolves.toBe(1);
});
it('en el último intento marca el informe como fallido y avisa', async () => {
generador.generar.mockRejectedValue(new Error('sin memoria'));
// attemptsMade = 2 con attempts = 3: este es el ÚLTIMO intento. La rama
// "ya no habrá reintentos" es la que nadie prueba y la que deja al
// usuario esperando un correo que no llegará jamás.
await expect(processor.process(job({ projectId: 'p-1' }, { attemptsMade: 2 })))
.rejects.toThrow('sin memoria');
expect(mailer.enviarFallo).toHaveBeenCalled();
});
});// Solo UN test de este tipo por cola: es el que verifica el cableado
// (nombre de cola, registro del procesador, serialización de la carga útil).
// Los casos de negocio se cubren en el nivel 2, que es cien veces más rápido.
describe('Cola de informes (integración)', () => {
let redis: StartedRedisContainer; let queue: Queue; let events: QueueEvents;
beforeAll(async () => {
redis = await new RedisContainer('redis:7-alpine').start();
// ... arrancar el módulo de Nest apuntando a redis.getConnectionUrl()
events = new QueueEvents('informes', { connection });
await events.waitUntilReady();
}, 60_000); // el timeout por defecto de 5 s no basta
afterAll(async () => { await events.close(); await queue.close(); await redis.stop(); });
it('procesa el trabajo publicado por el endpoint', async () => {
await http().post('/api/v1/projects/p-1/report').set(auth).expect(202);
const [job] = await queue.getJobs(['waiting', 'active', 'delayed']);
// ESPERAR AL EVENTO, jamás un setTimeout arbitrario: un sleep de 500 ms
// es lento cuando sobra y es intermitente cuando falta.
const resultado = await job.waitUntilFinished(events, 20_000);
expect(resultado.url).toMatch(/^https:\/\//);
await expect(orm.em.fork().count(Informe, {})).resolves.toBe(1);
});
});13.9 Testear el tiempo y la aleatoriedad
El tiempo y el azar son entradas ocultas de tu programa. No aparecen en la firma de ningún método, no se ven en el diagrama de dependencias y sin embargo cambian el resultado. Un test es un experimento reproducible; si su resultado depende del instante en que se ejecuta, deja de ser reproducible y se convierte en una lotería que a veces sale bien. Y saldrá bien durante meses, hasta el día del cambio de hora, o el día 31, o el 29 de febrero, o a las 23:58 de un viernes, cuando alguien tenga que averiguar por qué el pipeline está en rojo sin que nadie haya tocado nada.
13.9.1 Inyectar el reloj
La solución de fondo no es un truco de test, sino de diseño: convertir el tiempo en una dependencia explícita. Un Clock es una interfaz de un solo método, cuesta cinco líneas y elimina de golpe toda una
familia de fallos intermitentes. Además tiene un efecto secundario valioso: al leer el constructor de una clase sabes de inmediato que su comportamiento depende del tiempo, información que new Date() escondía
dentro de un método.
@Injectable()
export class SubscriptionsService {
estaVigente(s: Subscription): boolean {
// Dependencia invisible del reloj del sistema.
return s.expiresAt > new Date();
}
renovar(s: Subscription): void {
const base = new Date();
base.setMonth(base.getMonth() + 1); // ¿y el 31 de enero?
s.expiresAt = base;
}
}
// El test que "prueba" esto:
it('caduca en un mes', () => {
service.renovar(s);
// Recalcula la fecha con la MISMA fórmula del código:
// si la fórmula está mal, el test también lo está y
// ambos coinciden en el error. No verifica nada.
const esperado = new Date();
esperado.setMonth(esperado.getMonth() + 1);
expect(s.expiresAt.getMonth()).toBe(esperado.getMonth());
});export abstract class Clock { abstract now(): Date; }
@Injectable()
export class SystemClock extends Clock { now() { return new Date(); } }
export class FixedClock extends Clock {
constructor(private t: Date) { super(); }
now() { return new Date(this.t); } // copia: nadie la muta
avanzar(ms: number) { this.t = new Date(this.t.getTime() + ms); }
}
@Injectable()
export class SubscriptionsService {
constructor(private readonly clock: Clock) {}
estaVigente(s: Subscription) { return s.expiresAt > this.clock.now(); }
}
// El test fija la entrada y afirma la salida EXACTA, sin recalcular. Los tres
// casos son fechas que en su día rompieron implementaciones reales.
it.each([
['2026-01-31', '2026-02-28'], // fin de mes corto: no existe el 31 de febrero
['2024-02-29', '2024-03-29'], // año bisiesto
['2026-10-25', '2026-11-25'], // día del cambio de hora en España
])('renovar el %s vence el %s', (hoy, esperado) => {
const service = new SubscriptionsService(new FixedClock(new Date(hoy)));
expect(service.renovar(s).toISOString().slice(0, 10)).toBe(esperado);
});randomUUID() y Math.random() hacen que los identificadores cambien en cada ejecución e impiden comparar objetos completos con
toEqual. process.env convierte el entorno en un parámetro invisible. El sistema de ficheros, la red y os.hostname() hacen lo propio. La regla general es la misma para todas:
lo que no se inyecta, no se controla; y lo que no se controla, no se testea. Un IdGenerator, un Clock y un objeto de configuración tipado resuelven el noventa por ciento de los casos y, de
paso, hacen honesto el constructor de tus clases.
13.9.2 Congelar y avanzar el tiempo
Cuando el tiempo no está inyectado —código heredado, una biblioteca de terceros, un setTimeout interno— quedan los temporizadores falsos de Jest, que desde la versión 27 usan @sinonjs/fake-timers y
sustituyen Date, setTimeout, setInterval, process.hrtime y performance.now por implementaciones controladas por ti. La ganancia no es solo la reproducibilidad:
un test de un reintento con espera exponencial de treinta segundos pasa a durar microsegundos, porque avanzar el reloj es incrementar un número.
describe('withRetry (backoff exponencial)', () => {
beforeEach(() => {
jest.useFakeTimers({
now: new Date('2026-03-15T10:00:00.000Z'),
// CRÍTICO en Node: si falsificas nextTick y setImmediate, el driver de
// PostgreSQL, los sockets y las propias promesas dejan de progresar y
// el test se cuelga sin mensaje. Excluirlos es casi siempre correcto.
doNotFake: ['nextTick', 'setImmediate'],
});
});
afterEach(() => jest.useRealTimers()); // sin esto, contaminas los tests siguientes
it('reintenta 3 veces con 1 s, 2 s y 4 s de espera', async () => {
const op = jest.fn()
.mockRejectedValueOnce(new Error('503')).mockRejectedValueOnce(new Error('503'))
.mockResolvedValue('ok');
const promesa = withRetry(op, { intentos: 3, baseMs: 1000 });
// advanceTimersByTimeAsync (Jest 29+) vacía también la cola de microtareas:
// la versión síncrona avanza el reloj pero deja los await sin resolver, y
// el test se queda esperando para siempre. Es el error más común aquí.
await jest.advanceTimersByTimeAsync(1000);
expect(op).toHaveBeenCalledTimes(2);
await jest.advanceTimersByTimeAsync(2000);
await expect(promesa).resolves.toBe('ok');
expect(op).toHaveBeenCalledTimes(3);
});
it('no deja temporizadores pendientes al terminar', async () => {
await withRetry(jest.fn().mockResolvedValue('ok'), { intentos: 3 });
// Un timer huérfano es una fuga: en producción mantiene vivo el proceso e
// impide un apagado limpio; en Jest provoca el aviso "did not exit".
expect(jest.getTimerCount()).toBe(0);
});
it('el timestamp escrito es exactamente el momento congelado', async () => {
jest.setSystemTime(new Date('2026-12-31T23:59:59.000Z'));
const t = await service.crear({ title: 'Nochevieja' });
expect(t.createdAt.toISOString()).toBe('2026-12-31T23:59:59.000Z');
});
});Clock y sustitúyelo con
.overrideProvider(Clock).useValue(new FixedClock(...)), que congela el tiempo de tu dominio sin tocar el de la infraestructura. Y si no queda más remedio, activa los falsos justo alrededor de la porción
síncrona y desactívalos antes de cualquier E/S.
13.9.3 Probar tareas programadas sin esperar a que salten
Un @Cron('0 3 * * *') plantea un problema evidente: nadie va a esperar a las tres de la mañana. La solución es separar cuándo se ejecuta de qué hace, que además es la separación correcta desde el
punto de vista del diseño. El «qué» es un método público normal que se prueba como cualquier otro. El «cuándo» es configuración, y se verifica leyendo el registro del planificador. Solo un tercer test, opcional, comprueba el
cableado disparando el trabajo a mano.
@Injectable()
export class NightlyService {
@Cron('0 3 * * *', { name: 'purga-nocturna', timeZone: 'Europe/Madrid' })
async handleCron(): Promise<void> { await this.purgar(); }
// La LÓGICA vive aquí, en un método público, testeable y reutilizable
// desde un comando de CLI o desde un endpoint de administración.
async purgar(): Promise<number> { /* ... */ }
}
describe('NightlyService', () => {
it('purga las tareas archivadas hace más de 90 días', async () => {
// 1· El QUÉ: un test normal, sin cron, sin esperas, con reloj fijo.
clock.set('2026-06-01T03:00:00Z');
await crear(em, taskFactory({ archivedAt: DIA('2026-01-01') }), // 151 días
taskFactory({ archivedAt: DIA('2026-05-15') }), // 17 días
taskFactory({ archivedAt: null }));
await expect(service.purgar()).resolves.toBe(1);
await expect(em.fork().count(Task, {})).resolves.toBe(2);
});
it('está programado a las 03:00 con la zona horaria correcta', async () => {
// 2· El CUÁNDO: es configuración, y una configuración mal escrita es un
// bug silencioso. Con timeZone mal puesta, la purga se ejecuta a las 02:00
// en invierno y a las 04:00 en verano, o dos veces la noche del cambio.
const job = registry.getCronJob('purga-nocturna');
expect(job.cronTime.source).toBe('0 3 * * *');
expect(job.cronTime.timeZone).toBe('Europe/Madrid');
// Con la próxima ejecución también se puede afirmar sobre fechas concretas:
expect(job.nextDate().toISO()).toContain('T03:00:00');
});
it('el disparo manual del trabajo invoca la lógica (cableado)', async () => {
// 3· El CABLEADO: fireOnTick() ejecuta el callback registrado sin esperar
// al horario. Comprueba que el decorador apunta al método correcto, que es
// lo único que los dos tests anteriores no cubren. La API pertenece a la
// librería 'cron' que usa @nestjs/schedule: confirma su nombre en tu versión.
const espia = jest.spyOn(service, 'purgar').mockResolvedValue(0);
await registry.getCronJob('purga-nocturna').fireOnTick();
expect(espia).toHaveBeenCalledTimes(1);
});
});@Cron se dispara tres veces, una por instancia. Con una purga es molesto; con un cobro es un
incidente con clientes afectados. La solución es un cerrojo distribuido —SELECT ... FOR UPDATE sobre una fila de control, un SET NX en Redis o una tabla de leases— y merece su propio test de
integración: dos instancias arrancadas a la vez y una sola ejecución efectiva.
13.9.4 Aleatoriedad: semillas, identificadores y datos generados
El azar entra en los tests por tres puertas. La primera son los identificadores: si el código llama a randomUUID(), no puedes comparar el objeto completo con toEqual y acabas escribiendo
aserciones parciales que dejan huecos. La segunda son los datos generados con Faker, que sin semilla producen un escenario distinto en cada ejecución: el día que genera un nombre de sesenta caracteres o dos correos
iguales, el fallo aparece en el ordenador de otra persona y es irreproducible. La tercera es la lógica que usa el azar de forma deliberada: un muestreo, un reparto de carga, una prueba A/B.
import { faker } from '@faker-js/faker';
// Semilla fija: la MISMA secuencia en tu portátil, en el de tu compañera y en
// CI. Si un test falla, falla en todas partes, que es justo lo que quieres.
beforeEach(() => { faker.seed(20260315); contador = 0; });
// Los valores que deben ser ÚNICOS no se generan al azar: se cuentan. Faker
// puede repetir, y una violación de unicidad intermitente cuesta días.
let contador = 0;
export const emailUnico = () => `usuario-${++contador}@example.test`;
// Identificadores deterministas: un IdGenerator inyectable.
export abstract class IdGenerator { abstract next(): string; }
export class UuidGenerator extends IdGenerator { next() { return randomUUID(); } }
export class SequentialIdGenerator extends IdGenerator {
private n = 0;
next() { return `00000000-0000-4000-8000-${String(++this.n).padStart(12, '0')}`; }
}
// Con ids predecibles la aserción es completa y legible, sin expect.any(String):
it('crea la tarea con el id esperado', async () => {
await expect(service.crear({ title: 'A' })).resolves.toEqual({
id: '00000000-0000-4000-8000-000000000001',
title: 'A', status: TaskStatus.Todo, createdAt: AHORA,
});
});
// Y para la lógica que USA el azar, inyecta también la fuente aleatoria:
// constructor(private readonly random: () => number = Math.random) {}
// En el test: new Muestreador(() => 0.05) → cae dentro del 10% muestreado.fast-check genera cientos de entradas buscando un contraejemplo. Cuando lo encuentra, lo reduce hasta el caso mínimo que
falla y te da la semilla para reproducirlo. Es especialmente rentable en normalizadores, parseadores, cálculos de precios y paginación, precisamente donde la imaginación humana para inventar casos límite se agota antes que la
realidad.
13.10 Tests end-to-end con Supertest
Un e2e arranca la aplicación real —módulos, guards globales, pipes, filtros, interceptores, base de datos— y habla con ella por HTTP. Supertest levanta el servidor en un puerto efímero, así que no hay que elegir puerto ni temer colisiones. Es el único nivel que responde a «¿funciona esto de verdad para un cliente?».
export async function createTestApp(customize?: (b: TestingModuleBuilder) => void) {
const builder = Test.createTestingModule({ imports: [AppModule] })
// Lo único que se sustituye: efectos hacia el exterior.
.overrideProvider(MailService).useValue({ send: jest.fn().mockResolvedValue(undefined) })
.overrideProvider(PaymentsGateway).useValue({ charge: jest.fn().mockResolvedValue({ ok: true }) });
customize?.(builder);
const moduleRef = await builder.compile();
const app = moduleRef.createNestApplication({ logger: false }); // sin ruido
configureApp(app); // ← LA MISMA función que usa main.ts
await app.init();
return { app, moduleRef, orm: app.get(MikroORM),
http: () => request(app.getHttpServer()) };
}// main.ts
const app = await NestFactory.create(AppModule);
app.useGlobalPipes(new ValidationPipe({
whitelist: true, forbidNonWhitelisted: true, transform: true }));
app.useGlobalFilters(new AllExceptionsFilter());
app.setGlobalPrefix('api/v1');
// tasks.e2e-spec.ts
const app = mod.createNestApplication();
await app.init(); // ¡sin pipes, sin filtros, sin prefijo!
it('rechaza un título vacío', async () => {
// Pasa en verde: el DTO NUNCA se valida, porque el
// ValidationPipe no está. En producción devuelve 400...
// o peor, guarda un título vacío en la base de datos.
await request(app.getHttpServer()).post('/tasks')
.send({ title: '' }).expect(201);
});
// Además las rutas del test son /tasks y las reales
// /api/v1/tasks: el e2e no prueba las URLs de verdad.// src/configure-app.ts — única fuente de verdad
export function configureApp(app: INestApplication): void {
app.setGlobalPrefix('api/v1');
app.useGlobalPipes(new ValidationPipe({
whitelist: true, forbidNonWhitelisted: true, transform: true,
transformOptions: { enableImplicitConversion: true } }));
app.useGlobalFilters(new AllExceptionsFilter());
app.useGlobalInterceptors(new ClassSerializerInterceptor(
app.get(Reflector), { excludeExtraneousValues: true }));
app.enableVersioning({ type: VersioningType.URI });
app.enableShutdownHooks();
}
// main.ts
const app = await NestFactory.create(AppModule, { bufferLogs: true });
configureApp(app);
await app.listen(process.env.PORT ?? 3000);
// El e2e usa createTestApp(), que llama a configureApp():
it('rechaza un título vacío con 400', async () => {
await http().post('/api/v1/tasks').set(auth)
.send({ title: '' }).expect(400);
});13.10.1 Autenticación en los tests
| Opción | A favor | En contra | Cuándo |
|---|---|---|---|
Token real por POST /auth/login | Prueba de verdad el registro, el hash, el login y el guard; el token es idéntico al de producción | Añade dos peticiones por suite y acopla todos los tests al flujo de login | Por defecto, encapsulado en un helper loginAs() ejecutado una vez |
Token firmado con el JwtService de la app | Instantáneo; permite forjar cualquier rol, un token expirado o con claims raros | No ejercita el login; si cambia el contenido del payload, el test puede quedar obsoleto | Para probar autorización, expiración y casos límite del token |
Sustituir el guard con overrideGuard | Trivial y rapidísimo | Deja de probar la seguridad: es exactamente la parte que no puedes permitirte no probar | Solo en tests centrados en otra cosa, y nunca en la suite de autorización |
describe('Flujo completo de un usuario (e2e)', () => {
let app: INestApplication; let orm: MikroORM;
let http: () => request.SuperTest<request.Test>;
let token: string; let projectId: string; let taskId: string;
beforeAll(async () => { ({ app, orm, http } = await createTestApp()); });
afterAll(async () => { await orm.close(true); await app.close(); });
beforeAll(async () => { await truncateAll(orm); });
const auth = () => ({ Authorization: `Bearer ${token}` });
// Este describe es un ESCENARIO: los pasos comparten estado a propósito
// y van en orden. Los demás archivos deben ser independientes.
it('1· registro: 201 y no devuelve el hash de la contraseña', async () => {
const { body } = await http().post('/api/v1/auth/register')
.send({ email: 'ana@example.com', password: 'S3gura!2026', name: 'Ana' })
.expect(201);
expect(body).toMatchObject({ email: 'ana@example.com' });
expect(body).not.toHaveProperty('password');
expect(body).not.toHaveProperty('passwordHash'); // fuga clásica
// Efecto en la base de datos: la contraseña se guarda hasheada.
const user = await orm.em.fork().findOneOrFail(User, { email: 'ana@example.com' });
expect(user.passwordHash).not.toBe('S3gura!2026');
expect(user.passwordHash).toMatch(/^\$2[aby]\$/); // bcrypt
});
it('2· login: 200 con token, y 401 con contraseña incorrecta', async () => {
await http().post('/api/v1/auth/login')
.send({ email: 'ana@example.com', password: 'mal' }).expect(401);
const { body } = await http().post('/api/v1/auth/login')
.send({ email: 'ana@example.com', password: 'S3gura!2026' }).expect(200);
token = body.accessToken;
expect(token.split('.')).toHaveLength(3);
});
it('3· sin token: 401; con token: crea el proyecto y devuelve Location', async () => {
await http().post('/api/v1/projects').send({ name: 'X' }).expect(401);
const res = await http().post('/api/v1/projects').set(auth())
.send({ name: 'Migración a Nest' }).expect(201);
projectId = res.body.id;
expect(res.headers.location).toBe(`/api/v1/projects/${projectId}`);
});
it('4· crea dos tareas dentro del proyecto', async () => {
const crear = (title: string, tags: string[], dueDate: string) =>
http().post(`/api/v1/projects/${projectId}/tasks`).set(auth())
.send({ title, tags, dueDate }).expect(201);
taskId = (await crear('Escribir tests', ['qa'], '2026-04-01')).body.id;
await crear('Revisar PR', ['review'], '2026-04-05');
});
it('5· lista con filtro y paginación', async () => {
const { body } = await http()
.get(`/api/v1/projects/${projectId}/tasks?tag=qa&page=1&limit=10`)
.set(auth()).expect(200);
expect(body.total).toBe(1);
expect(body.items[0]).toMatchObject({ id: taskId, title: 'Escribir tests' });
});
it('6· otro usuario no puede ver ni borrar la tarea (403, no 404 genérico)', async () => {
const otro = await registrarYLogin(http, 'bob@example.com');
await http().delete(`/api/v1/tasks/${taskId}`)
.set({ Authorization: `Bearer ${otro}` }).expect(403);
});
it('7· borra la tarea: 204 sin cuerpo, y desaparece de la base de datos', async () => {
const res = await http().delete(`/api/v1/tasks/${taskId}`).set(auth()).expect(204);
expect(res.body).toEqual({});
await expect(orm.em.fork().findOne(Task, { id: taskId })).resolves.toBeNull();
});
it('8· la tarea borrada da 404, y un id con formato inválido da 400', async () => {
await http().get(`/api/v1/tasks/${taskId}`).set(auth()).expect(404);
await http().get('/api/v1/tasks/no-es-un-uuid').set(auth()).expect(400);
});
});Math.random() sin semilla; sin --runInBand, un esquema o
base de datos por worker (13.7.3). Un archivo que solo pasa cuando se ejecuta el primero no es un test: es una bomba de relojería en tu pipeline.
13.11 Dobles de prueba y diseño
La testabilidad no es una propiedad de los tests: es una propiedad del diseño que los tests revelan. Si para probar una regla de negocio necesitas quince mocks, o hay que arrancar la aplicación completa, o el test depende de la hora del sistema, el problema no es el test. Merece la pena leer la dificultad como un diagnóstico:
| Síntoma en el test | Problema de diseño | Principio SOLID |
|---|---|---|
| Muchos mocks para un solo caso | La clase tiene demasiadas responsabilidades y colaboradores | S: responsabilidad única |
| Hay que mockear métodos que el caso no usa | Dependes de interfaces demasiado anchas | I: segregación de interfaces |
Imposible sustituir una dependencia (new o import directo) | Acoplamiento a una implementación concreta | D: inversión de dependencias |
Hay que mockear Date, randomUUID, fetch | Efectos no explícitos escondidos en el dominio | D: inyecta Clock, IdGenerator, HttpClient |
| Añadir un caso obliga a tocar el test de los demás | Condicionales en cascada donde debería haber polimorfismo | O: abierto/cerrado |
| Un doble de la subclase rompe tests de la base | La subclase no cumple el contrato del padre | L: sustitución de Liskov |
Con el vocabulario de Gerard Meszaros (xUnit Test Patterns), los dobles no son todos lo mismo y usar el nombre correcto evita discusiones estériles: un dummy se pasa solo para rellenar un parámetro y nunca se
usa; un stub devuelve respuestas predefinidas para dirigir el flujo; un spy registra cómo se le llamó para poder comprobarlo después; un mock tiene expectativas y falla si no se cumplen; y un fake es una
implementación real pero simplificada, como un repositorio en memoria o SQLite. La regla que más ahorra dolor: usa stubs y fakes para las dependencias de consulta y reserva los mocks para las de mando. Verificar que
«se llamó a save» es frágil; verificar que «se envió el correo» es legítimo porque ese es el comportamiento observable.
13.12 Datos de prueba: factorías, seeders y builders
// test/fixtures.ts — 600 líneas y creciendo
export const usuarios = [ /* 40 usuarios */ ];
export const proyectos = [ /* 25 proyectos */ ];
export const tareas = [ /* 300 tareas */ ];
it('cuenta las tareas vencidas', async () => {
await cargarTodo(em);
const n = await service.countOverdue('u-7');
// ¿De dónde sale el 3? De contar a mano en un archivo
// de 600 líneas. Nadie se atreve a tocar el fixture
// porque no sabe qué tests dependen de qué fila, así
// que solo se AÑADE... y un día alguien añade una
// tarea vencida de u-7 y rompe este test.
expect(n).toBe(3);
});it('cuenta las tareas vencidas', async () => {
const user = await crear(em, userFactory());
// El test declara EXACTAMENTE su escenario: dos
// vencidas, una futura, una vencida pero ya hecha.
// No hace falta salir del test para entenderlo.
await crear(em,
taskFactory({ owner: user, dueDate: DIA('2026-01-01') }),
taskFactory({ owner: user, dueDate: DIA('2026-02-01') }),
taskFactory({ owner: user, dueDate: DIA('2027-01-01') }),
taskFactory({ owner: user, dueDate: DIA('2026-01-01'),
status: TaskStatus.Done }),
);
await expect(service.countOverdue(user.id,
DIA('2026-06-01'))).resolves.toBe(2);
});import { faker } from '@faker-js/faker';
// Los valores por defecto son VÁLIDOS pero irrelevantes: así el test solo
// escribe lo que importa, y ese delta es la documentación del caso.
export const taskFactory = (over: Partial<RequiredEntityData<Task>> = {})
: RequiredEntityData<Task> => ({
title: faker.lorem.sentence(3),
description: null, status: TaskStatus.Todo, priority: Priority.Medium,
tags: [], dueDate: null, createdAt: new Date('2026-01-01T00:00:00Z'),
...over,
});
// Un seeder de MikroORM para escenarios completos (demos, entorno local, e2e
// que necesitan volumen). En los tests unitarios, siempre factorías.
export class DemoSeeder extends Seeder {
async run(em: EntityManager): Promise<void> {
const owner = em.create(User, userFactory({ email: 'demo@example.com' }));
const project = em.create(Project, projectFactory({ owner, name: 'Demo' }));
for (let i = 0; i < 50; i++) em.create(Task, taskFactory({ project }));
// Sin flush: lo hace el SeedManager al terminar.
}
}faker.person.fullName() devuelve un nombre de 60 caracteres y revienta un varchar(50); otro día genera dos correos iguales y viola una restricción de unicidad. En
setupFilesAfterEnv: faker.seed(20260315) para reproducibilidad, y usa contadores para lo que deba ser único (email: `u${++n}@example.com`). Lo mismo con el tiempo: si el código lee
new Date(), congélalo con jest.useFakeTimers({ now: new Date('2026-03-15T10:00:00Z') })
o inyecta un Clock. El test que solo falla el día 1 de cada mes, o de 23:00 a 00:00 en horario de verano, es un clásico que cuesta días localizar.
13.13 Contratos entre servicios
Hasta aquí todos los tests viven dentro de un mismo repositorio y comprueban que el backend hace lo que su autor cree que debe hacer. Queda una pregunta que ningún test interno responde: ¿sigue el backend cumpliendo lo que
sus consumidores esperan de él? El frontend Angular de TaskFlow, la aplicación móvil y el servicio de facturación dependen de la forma exacta de tus respuestas. Cada uno tiene su repositorio, su calendario y su equipo, y
ninguno de ellos aparece en tu suite. El día que renombras dueDate a deadline, tu suite sigue verde —los tests se renombraron con el código— y tres clientes se rompen a la vez.
La respuesta ingenua es un entorno de integración donde todo esté desplegado y unos tests end-to-end que lo recorran. Funciona, y es carísimo: hay que mantener el entorno, coordinar despliegues, y cuando algo falla nadie sabe de quién es la culpa. El contract testing propone otra cosa: en lugar de probar los sistemas juntos, se prueba el acuerdo entre ellos, y cada lado lo verifica por separado en su propio pipeline, en milisegundos y sin desplegar nada.
GET /tasks/:id devuelva un objeto con id y title de tipo texto»; el proveedor verifica contra esa declaración. Ninguno de los dos necesita al otro
levantado, y cuando hay discrepancia el mensaje de error dice exactamente qué cláusula se incumplió y quién la incumplió.
13.13.1 Dos enfoques: dirigido por el consumidor o dirigido por el esquema
| Contratos del consumidor (Pact) | Contrato del proveedor (OpenAPI) | |
|---|---|---|
| Quién define el contrato | Cada consumidor, escribiendo un test contra un servidor simulado | El proveedor, generando el esquema desde sus decoradores |
| Qué se verifica | Que el proveedor satisface lo que cada consumidor usa realmente | Que la API no ha cambiado de forma incompatible y que las respuestas se ajustan al esquema |
| Ventaja decisiva | Detecta que puedes borrar un campo que nadie usa, y que no puedes borrar el que sí usa alguien | Coste casi nulo: el esquema ya existe si documentas con Swagger. Genera clientes tipados gratis |
| Coste | Alto: exige un broker, disciplina en ambos lados y coordinación organizativa | Bajo: dos ficheros de CI. No sabe qué usa cada consumidor |
| Cuándo | Varios equipos y servicios independientes, despliegues descoordinados | El punto de partida sensato para un equipo con un frontend y un backend, como TaskFlow |
En un producto como TaskFlow, con un Angular y un NestJS que evolucionan en paralelo, el enfoque de esquema resuelve el noventa por ciento del problema con una fracción del esfuerzo, porque el esquema OpenAPI que ya genera
@nestjs/swagger a partir de tus DTO puede cumplir tres funciones a la vez: documentación, fuente del cliente tipado del frontend y detector automático de cambios incompatibles.
13.13.2 Del esquema al cliente tipado y a la comprobación automática
// 1· Generar el esquema sin arrancar un servidor HTTP: rápido y determinista.
export async function generarEsquema(): Promise<OpenAPIObject> {
const app = await NestFactory.create(AppModule, { logger: false });
configureApp(app); // el MISMO prefijo y versionado
const doc = SwaggerModule.createDocument(app, new DocumentBuilder()
.setTitle('TaskFlow API').setVersion(process.env.APP_VERSION ?? '1.0.0').build());
await app.close();
return doc;
}
// 2· Guardarlo en el repositorio y comprobarlo en cada ejecución. Si alguien
// cambia un DTO, este test falla y le OBLIGA a mirar el diff del esquema: el
// cambio deja de ser accidental y pasa a ser una decisión consciente.
it('el esquema público no ha cambiado sin querer', async () => {
const actual = await generarEsquema();
expect(actual).toMatchSnapshot(); // o comparar con openapi.json versionado
});
// 3· Y verificar que las RESPUESTAS reales se ajustan al esquema. Un contrato
// que nadie comprueba en ejecución es documentación, no contrato.
import jestOpenAPI from 'jest-openapi';
beforeAll(async () => jestOpenAPI(await generarEsquema()));
it('GET /tasks/:id satisface el esquema declarado', async () => {
const res = await http().get(`/api/v1/tasks/${id}`).set(auth).expect(200);
// Detecta lo que ningún toMatchObject detecta: campos declarados que no
// llegan, tipos que no coinciden, formatos date-time rotos, nulos donde el
// esquema dice que no puede haberlos.
expect(res).toSatisfyApiSpec();
});jobs:
contrato:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with: { fetch-depth: 0 }
- run: npm ci && npm run openapi:generate # escribe openapi.json
# oasdiff compara dos esquemas y clasifica los cambios. Solo los
# BREAKING rompen el pipeline: añadir un campo opcional o un endpoint
# nuevo es retrocompatible y no debe molestar a nadie.
- name: Cambios incompatibles frente a main
run: |
git show origin/main:openapi.json > /tmp/base.json
npx @tufin/oasdiff breaking /tmp/base.json openapi.json --fail-on ERR
# El cliente del frontend se GENERA, no se escribe a mano: así el
# compilador de TypeScript del consumidor detecta la ruptura en cuanto
# se regenera, sin necesidad de ejecutar nada.
- run: npx openapi-typescript openapi.json -o ../frontend/src/api/schema.d.ts
- run: cd ../frontend && npx tsc --noEmitEse último paso es el que cierra el círculo y merece subrayarse: convertir el esquema en tipos del consumidor traslada la verificación del contrato al compilador. Si el backend renombra dueDate, el
frontend deja de compilar en el momento de regenerar el cliente, con un error que señala la línea exacta. No hay que ejecutar tests, ni desplegar, ni interpretar un fallo intermitente: es el mismo mecanismo que evita que llames
a un método que no existe dentro de tu propio proyecto, extendido a la frontera entre dos repositorios. Herramientas como openapi-typescript generan solo los tipos, mientras que orval o
ng-openapi-gen generan además los servicios de Angular listos para inyectar.
string a enum), cambiar un código de estado
o el formato de un identificador, y añadir una validación más estricta. No rompen: añadir un campo opcional a la entrada, añadir un campo a la respuesta —siempre que tus consumidores no validen en modo estricto—, y
añadir endpoints. La asimetría se conoce como ley de Postel aplicada a las API: sé estricto con lo que emites y tolerante con lo que aceptas. Y cuando el cambio incompatible sea inevitable, la salida es versionar
(/api/v2) y mantener la versión anterior durante un plazo anunciado, no negociar por chat con cada consumidor.
13.14 Pruebas de carga y de rendimiento
Los tests funcionales responden a «¿hace lo correcto?». Las pruebas de carga responden a una pregunta distinta y complementaria: «¿lo sigue haciendo cuando hay mil usuarios a la vez, y qué ocurre exactamente cuando deja de hacerlo?». Es información que no se puede deducir leyendo el código, porque los sistemas no se degradan de forma lineal: aguantan, aguantan, y a partir de cierto punto se desploman. Conocer dónde está ese punto —y qué recurso lo provoca— es la diferencia entre dimensionar con datos y dimensionar con intuición.
13.14.1 Cuatro pruebas distintas que suelen confundirse
| Prueba | Pregunta que responde | Patrón de carga | Qué revela |
|---|---|---|---|
| De carga (load) | ¿Cumplimos el SLO con el tráfico esperado? | Subida gradual hasta la carga nominal y meseta de 10–30 min | Latencia y errores en condiciones normales. Es la prueba de referencia, la que se ejecuta en cada versión |
| De estrés (stress) | ¿Dónde está el límite y cómo se rompe? | Subida continuada más allá de la capacidad, hasta la degradación | El punto de saturación y el modo de fallo: si degrada con elegancia (429, colas) o si se cae y no se recupera |
| De resistencia (soak) | ¿Aguanta horas sin degradarse? | Carga moderada y constante durante 2–24 h | Fugas de memoria, conexiones que no se devuelven al pool, ficheros abiertos, crecimiento de tablas, cachés sin expiración |
| De picos (spike) | ¿Sobrevive a un golpe repentino y se recupera? | Salto brusco de x1 a x20 durante 1–2 min y vuelta atrás | Si el autoescalado llega a tiempo, si el circuit breaker actúa, y sobre todo si el sistema vuelve a la normalidad o queda en un estado degradado permanente |
Hay dos parientes que conviene nombrar aunque se usen menos. La prueba de humo de rendimiento es una ejecución de un minuto con cinco usuarios que se lanza en cada pull request: no mide capacidad, pero detecta el día que alguien introduce un N+1 que multiplica por veinte la latencia. Y la prueba de capacidad busca el número máximo de usuarios concurrentes con el que aún se cumple el SLO, que es el dato que se necesita para decidir cuántas réplicas desplegar.
13.14.2 Un ejemplo ejecutable con k6
k6 es un ejecutor escrito en Go que se programa en JavaScript. La ventaja frente a herramientas más simples es que permite describir escenarios realistas —varios pasos, pausas, correlación de datos entre peticiones— y, sobre todo, definir umbrales que hacen que el proceso termine con código de salida distinto de cero: eso convierte una prueba de carga en un test que un pipeline puede exigir.
import http from 'k6/http';
import { check, group, sleep } from 'k6';
import { Trend, Rate } from 'k6/metrics';
const latenciaListado = new Trend('latencia_listado_ms');
const erroresNegocio = new Rate('errores_negocio');
export const options = {
scenarios: {
// ramping-vus modela USUARIOS concurrentes: cada uno espera la respuesta
// antes de seguir. Es el modelo realista para una API con clientes reales.
carga: { executor: 'ramping-vus', startVUs: 0, stages: [
{ duration: '1m', target: 50 }, // rampa: nunca empieces en frío al máximo
{ duration: '5m', target: 50 }, // meseta: aquí se miden los percentiles
{ duration: '1m', target: 0 }, // bajada: comprueba que se recupera
] },
// ramping-arrival-rate modela PETICIONES POR SEGUNDO con independencia de
// lo que tarde el servidor. Es el modelo correcto para un pico: los
// usuarios reales no dejan de pulsar porque tu servidor vaya lento.
picos: { executor: 'ramping-arrival-rate', startTime: '8m',
startRate: 20, timeUnit: '1s', preAllocatedVUs: 300,
stages: [{ duration: '30s', target: 20 }, { duration: '15s', target: 400 },
{ duration: '1m', target: 400 }, { duration: '30s', target: 20 }] },
},
thresholds: {
// El umbral ES el SLO escrito como código. Si no se cumple, k6 termina
// con código 1 y el pipeline se pone en rojo, igual que un test unitario.
'http_req_duration{escenario:listado}': ['p(95)<300', 'p(99)<800'],
'http_req_failed': ['rate<0.01'], // menos del 1% de errores
'errores_negocio': ['rate<0.001'],
// abortOnFail corta la ejecución en cuanto se incumple: no tiene sentido
// seguir castigando un sistema que ya sabemos que no cumple.
'http_req_duration{escenario:escritura}': [{ threshold: 'p(95)<500', abortOnFail: true }],
},
};
export function setup() { // se ejecuta UNA vez, antes de todo
const r = http.post(`${__ENV.BASE_URL}/api/v1/auth/login`,
JSON.stringify({ email: 'carga@example.test', password: __ENV.PASS }),
{ headers: { 'Content-Type': 'application/json' } });
return { token: r.json('accessToken') };
}
export default function (data) {
const auth = { headers: { Authorization: `Bearer ${data.token}` } };
group('listado con filtro y paginación', () => {
// La página 1 sale de la caché y no representa nada: hay que pedir páginas
// dispersas y filtros variados, o estarás midiendo tu caché, no tu API.
const page = Math.floor(Math.random() * 20) + 1;
const res = http.get(`${__ENV.BASE_URL}/api/v1/tasks?page=${page}&limit=20&tag=qa`,
Object.assign({ tags: { escenario: 'listado' } }, auth));
latenciaListado.add(res.timings.duration);
// Comprobar el CUERPO, no solo el estado: un servidor saturado puede
// devolver 200 con una lista vacía y la prueba parecería perfecta.
const ok = check(res, {
'estado 200': (r) => r.status === 200,
'devuelve elementos': (r) => r.json('items').length > 0,
});
erroresNegocio.add(!ok);
});
sleep(Math.random() * 2 + 1); // tiempo de reflexión: un usuario no dispara
} // peticiones sin parar, y sin sleep mides otra cosaautocannon
Cuando solo quieres saber si un endpoint concreto ha empeorado, instalar k6 es desproporcionado. npx autocannon -c 100 -d 30 -p 10 -H "Authorization: Bearer $T" http://localhost:3000/api/v1/tasks lanza cien
conexiones durante treinta segundos con diez peticiones en vuelo por conexión y devuelve una tabla con la distribución de latencias y el caudal. Es perfecto para comparar dos commits —el antes y el después de una
optimización— y se puede invocar desde un script de Node para incorporarlo a un test. Lo que no hace es modelar escenarios de varios pasos ni fallar por umbrales sin código adicional.
13.14.3 Qué mirar y cómo interpretarlo
La primera regla es la misma que en las métricas de producción: los percentiles, nunca la media. Un endpoint con una media de 80 ms puede tener un p99 de cuatro segundos, y ese p99 no es «un caso raro»: si tu página hace diez llamadas, la probabilidad de que al menos una caiga en el p99 es cercana al diez por ciento. La cola de la distribución es la experiencia real de una parte nada despreciable de tus usuarios.
La segunda es que hay que mirar cuatro números a la vez, porque cada uno por separado engaña: el caudal (peticiones por segundo atendidas), la latencia por percentiles, la tasa de error y la saturación de los recursos (CPU, memoria, conexiones ocupadas del pool, profundidad de las colas). Un sistema que mantiene el caudal pero dispara la latencia está encolando; uno que mantiene la latencia pero pierde caudal está rechazando trabajo; y uno que mejora la latencia mientras sube la tasa de error probablemente esté fallando rápido, que parece bueno en la gráfica y es pésimo para el usuario.
LATENCIA FRENTE A CONCURRENCIA · la curva que hay que saber leer
p95 (ms)
2000 ┤ ╭──────── ✗ COLAPSO
│ ╭─╯ errores,
1500 ┤ ╭─╯ timeouts
│ ╭───────╯
1000 ┤ ╭─────╯ ← zona de saturación:
│ ╭──────╯ la cola crece sin
500 ┤ ╭────────╯ límite (Little)
│ ╭───────────────╯ ← RODILLA (~90 usuarios): capacidad real
100 ┤────────╯
└───┬────────┬────────┬────────┬────────┬────────┬────────┬─────► usuarios
20 50 90 120 150 200 300 concurrentes
caudal ▲ sube linealmente │ se aplana │ BAJA (thrashing: se gasta más tiempo
(rps) │ con la carga │ en el tope │ gestionando la cola que trabajando)
DIAGNÓSTICO SEGÚN DÓNDE ESTÁ EL LÍMITE
· CPU al 100% ................. límite de cómputo: optimiza o añade réplicas
· CPU baja y latencia alta ..... ESPERA: base de datos, red o pool agotado
· pool de conexiones al 100% ... el cuello es la BD; subir réplicas EMPEORA
· lag del event loop > 50 ms ... trabajo síncrono bloqueando el hilo
· memoria en escalera .......... fuga: se ve en la prueba de resistencia, no aquí
La rodilla de esa curva no es un accidente: es teoría de colas. La ley de Little establece que la concurrencia media es igual al caudal por la latencia media, de modo que si el caudal se ha aplanado en su máximo, cualquier usuario adicional solo puede traducirse en más latencia. Dicho de otro modo, a partir de la rodilla el sistema no está más lento porque haga más trabajo, sino porque las peticiones esperan en una cola. Y de ahí se deduce la consecuencia práctica más importante: añadir instancias solo ayuda si el recurso saturado es de la instancia. Si el cuello de botella es el pool de conexiones o la propia base de datos, duplicar réplicas duplica la presión sobre el mismo recurso y empeora la latencia.
arrival-rate y no con usuarios—. 5) Probar en un entorno con la mitad de recursos que producción y extrapolar linealmente: la degradación no es lineal, y precisamente el punto que buscas es
donde deja de serlo.
13.15 Logging
Los tests hablan del código que conoces; los logs, del que ya está corriendo. En producción no puedes poner un breakpoint: lo único que tienes es lo que tu aplicación tuvo la previsión de contar. Nest incluye un
Logger con contexto y niveles (log, error, warn, debug, verbose, fatal) que se declara por clase, se silencia por configuración y se puede sustituir por completo.
async close(id: string, userId: string) {
console.log('cerrando', id); // sin nivel, sin
console.log('usuario:', userId); // contexto, sin
try { // marca de tiempo
// ...
} catch (e) {
console.log('error!', e.message); // pierde el stack
// Y va a stdout como texto libre: no se puede filtrar
// por severidad, ni agrupar, ni correlacionar con la
// petición, ni silenciar en producción. Si el objeto
// contiene la contraseña, queda en el log para siempre.
throw e;
}
}private readonly logger = new Logger(TasksService.name);
async close(id: string, userId: string) {
// debug: útil al diagnosticar, apagado en producción.
this.logger.debug({ msg: 'cierre solicitado', id, userId });
try {
// ...
} catch (e) {
// error: incidencia inesperada, CON el stack y con
// datos estructurados para poder buscar por projectId.
this.logger.error({ msg: 'fallo al cerrar', projectId: id,
userId, err: e }, (e as Error).stack);
throw e;
}
}13.15.1 Logging estructurado en JSON con Pino
El logger por defecto es texto legible para humanos, perfecto en desarrollo e inservible a escala: no se puede consultar. Un log estructurado es un objeto JSON por línea, y eso convierte tus logs en una base de datos
consultable («todos los errores 500 del usuario u-7 en la última hora»). Pino con nestjs-pino es la opción habitual por rendimiento; Winston es más flexible y notablemente más lento.
LoggerModule.forRoot({
pinoHttp: {
level: process.env.LOG_LEVEL ?? (isProd ? 'info' : 'debug'),
// Un id por petición: si el cliente ya envía uno, se respeta, para poder
// seguir la traza entre servicios; y se devuelve para que el usuario que
// reporta un fallo pueda darte el identificador exacto.
genReqId: (req, res) => {
const id = (req.headers['x-request-id'] as string) ?? randomUUID();
res.setHeader('x-request-id', id);
return id;
},
// REDACCIÓN: obligatoria y por lista explícita de rutas conocidas.
redact: {
paths: ['req.headers.authorization', 'req.headers.cookie',
'res.headers["set-cookie"]', 'req.body.password', 'req.body.newPassword',
'req.body.token', 'req.body.card.number', 'req.body.card.cvv',
'*.passwordHash', '*.refreshToken'],
censor: '[REDACTADO]', remove: false, // saber que el campo existía es útil
},
// Serializadores: volcar el req entero es una fuga de datos garantizada.
serializers: { req: (r) => ({ id: r.id, method: r.method, url: r.url }),
res: (r) => ({ statusCode: r.statusCode }) },
// Cada respuesta con su nivel: 5xx es error nuestro, 4xx es aviso.
customLogLevel: (_req, res, err) =>
err || res.statusCode >= 500 ? 'error' : res.statusCode >= 400 ? 'warn' : 'info',
// MUESTREO: /health y /metrics generan miles de líneas sin información.
autoLogging: { ignore: (req) => ['/health', '/metrics'].includes(req.url!) },
// En desarrollo, salida legible; en producción, JSON puro a stdout, que
// recoge el orquestador. Nunca escribas archivos de log desde la aplicación.
transport: isProd ? undefined
: { target: 'pino-pretty', options: { singleLine: true, translateTime: 'HH:MM:ss.l' } },
base: { service: 'tasks-api', version: process.env.APP_VERSION,
instance: process.env.HOSTNAME }, // filtrar por versión e instancia
},
});
// main.ts: sustituye el logger de Nest por Pino, para que también los mensajes
// internos del framework salgan en JSON.
const app = await NestFactory.create(AppModule, { bufferLogs: true });
app.useLogger(app.get(Logger));redact funciona por rutas conocidas: el día que alguien añade req.body.pin o req.body.iban, ese campo se registra en claro y allí se queda, replicado en el sistema de logs, en
las copias de seguridad y probablemente en un tercero. La alternativa robusta es serializar solo los campos que
has decidido registrar. Y recuerda que los logs suelen tener menos control de acceso que la base de datos: si algo no puede salir en una captura de pantalla, no puede ir a un log.
13.15.2 Identificador de correlación con AsyncLocalStorage
Con concurrencia, las líneas de veinte peticiones se entrelazan. Sin un identificador común no puedes reconstruir ninguna. AsyncLocalStorage (el CLS de Node) mantiene un contexto que sobrevive a todos los
await de la misma cadena asíncrona, así que cualquier capa puede leerlo sin pasarlo por parámetro.
type Ctx = { requestId: string; userId?: string; traceId?: string };
const als = new AsyncLocalStorage<Ctx>();
export const RequestContext = {
run: (ctx: Ctx, fn: () => unknown) => als.run(ctx, fn),
get: () => als.getStore(),
set: (patch: Partial<Ctx>) => Object.assign(als.getStore() ?? {}, patch),
};
@Injectable()
export class RequestContextMiddleware implements NestMiddleware {
use(req: Request, res: Response, next: NextFunction) {
const requestId = (req.headers['x-request-id'] as string) ?? randomUUID();
res.setHeader('x-request-id', requestId);
RequestContext.run({ requestId, traceId: trace.getActiveSpan()?.spanContext().traceId },
() => next());
}
}
// Propagación: el mismo identificador debe viajar a TODAS las fronteras.
// 1) Llamadas HTTP salientes: cabecera x-request-id en el interceptor de Axios.
// 2) Colas: como metadato del job, y restaurado con RequestContext.run() en el worker.
// 3) Base de datos: como comentario SQL, para cruzar el log lento con la petición.
// Si se pierde en una frontera, la traza se corta justo donde más la necesitas.Sobre qué registrar y con qué nivel: en el borde HTTP, una línea por petición con método, ruta, estado y duración (info), generada automáticamente por pino-http. En la capa de aplicación, las
decisiones de negocio relevantes —«proyecto cerrado», «pago rechazado»— en info, y los detalles del flujo en debug. En la capa de datos, debug para las consultas y warn para las
lentas. Los errores: warn si es culpa del cliente y esperable (4xx), error con stack si es inesperado (5xx), fatal solo si el proceso no puede continuar. Y siempre incluye
identificadores (projectId, userId) en lugar de frases: un log sin ids no se puede cruzar con nada.
13.16 Métricas y trazas
| Pilar | Qué es | A qué pregunta responde | Coste |
|---|---|---|---|
| Logs | Eventos discretos con contexto | ¿Qué pasó exactamente en esta petición concreta? | Alto y creciente con el tráfico |
| Métricas | Series temporales numéricas agregadas | ¿Está el sistema sano? ¿Cuántos errores, cuánta latencia, cuánta saturación? | Bajo y constante: no depende del volumen |
| Trazas | El recorrido de una petición por todos los componentes | ¿Dónde se fue el tiempo? ¿Qué servicio o consulta es el cuello de botella? | Medio; se controla con muestreo |
El flujo sano de una investigación va de lo agregado a lo concreto: una alerta sobre una métrica avisa de que algo va mal, las métricas dicen qué y desde cuándo, una traza señala dónde se pierde el tiempo y los logs de esa petición explican por qué. Si te falta uno de los tres, alguna de esas preguntas se responde adivinando.
13.16.1 Métricas con Prometheus
Prometheus consulta periódicamente un endpoint /metrics que expone el estado actual en texto plano. Los tipos que usarás: contador (solo crece: peticiones, errores), histograma (distribución en
buckets, del que se derivan los percentiles), gauge (valor que sube y baja: conexiones activas, tamaño de una cola) y summary (percentiles precalculados, no agregables entre instancias: úsalo poco). El
método RED resume qué medir en un servicio (Rate, Errors, Duration) y USE en un recurso (Utilization, Saturation, Errors).
@Module({
imports: [PrometheusModule.register({ path: '/metrics',
defaultMetrics: { enabled: true } })], // CPU, memoria, lag del event loop, GC
providers: [
// R y E de RED: contador con etiquetas de BAJA cardinalidad.
makeCounterProvider({ name: 'http_requests_total', help: 'Peticiones atendidas',
labelNames: ['method', 'route', 'status'] }),
// D de RED: histograma en SEGUNDOS, con buckets ajustados al SLO.
makeHistogramProvider({ name: 'http_request_duration_seconds', help: 'Duración',
labelNames: ['method', 'route', 'status'],
buckets: [0.01, 0.05, 0.1, 0.3, 0.5, 1, 2.5, 5, 10] }),
// Saturación: el pool de la base de datos es el límite más habitual.
makeGaugeProvider({ name: 'db_pool_connections', help: 'Conexiones',
labelNames: ['state'] }),
],
})
export class MetricsModule {}
@Injectable()
export class MetricsInterceptor implements NestInterceptor {
constructor(
@InjectMetric('http_requests_total') private readonly total: Counter,
@InjectMetric('http_request_duration_seconds') private readonly dur: Histogram,
) {}
intercept(ctx: ExecutionContext, next: CallHandler): Observable<unknown> {
const req = ctx.switchToHttp().getRequest();
// CLAVE: la RUTA con parámetros (/tasks/:id), nunca la url concreta. Con
// /tasks/018f-… tendrías una serie temporal por id: explosión de
// cardinalidad que tumba al servidor de métricas.
const route = req.route?.path ?? 'unknown';
const fin = this.dur.startTimer({ method: req.method, route });
return next.handle().pipe(finalize(() => {
const status = String(ctx.switchToHttp().getResponse().statusCode);
fin({ status });
this.total.inc({ method: req.method, route, status });
}));
}
}histogram_quantile(0.95, sum(rate(http_request_duration_seconds_bucket[5m])) by (le, route)). Y la tasa de error como fracción, no en absoluto: sum(rate(http_requests_total{status=~"5.."}[5m])) / sum(rate(http_requests_total[5m])).
13.16.2 Trazas distribuidas con OpenTelemetry
Una traza es un árbol de spans; cada span es una operación con nombre, inicio, duración, atributos y un enlace a su padre. Todos los spans de una petición comparten el traceId, que se
propaga entre procesos por la cabecera traceparent del estándar W3C. Así se ve una petición lenta:
GET /api/v1/projects/p-1/tasks?tag=qa traceId: 4bf92f3577b34da6a3ce929d0e0e4736
──────────────────────────────────────────────────────────────────────────────────────
span │ 0ms 100 200 300 400 460
──────────────────────────────────────────────┼───────────────────────────────────────
HTTP GET /projects/:id/tasks [SERVER] │████████████████████████████████████████ 460ms
├─ JwtAuthGuard.canActivate │██ 18ms
├─ TasksService.findByProject │ ██████████████████████████████████████ 436ms
│ ├─ SELECT * FROM projects WHERE id = $1 │ ███ 12ms
│ ├─ SELECT * FROM tasks WHERE project_id=$1 │ ████ 21ms
│ ├─ SELECT * FROM users WHERE id = $1 │ ██ ← N+1 8ms
│ ├─ SELECT * FROM users WHERE id = $1 │ ██ (×40) 8ms
│ ├─ … 38 spans idénticos más │ ████████████████████████ 320ms
│ └─ POST http://billing/usage [CLIENT] │ ██████ 62ms
│ └─ (otro servicio, MISMO traceId) │ █████ 55ms
└─ ClassSerializerInterceptor │ █ 6ms
──────────────────────────────────────────────────────────────────────────────────────
DIAGNÓSTICO: 40 spans de consulta idénticos = problema N+1. Falta populate: ['owner'].
Ninguna consulta es lenta por separado (8ms); la SUMA es el 70% del tiempo. Una métrica
solo diría "p95 alto"; los logs, "muchas consultas". La traza señala la línea exacta.
// El SDK parchea los módulos al cargarlos: si se inicializa después de
// importar Express o pg, la instrumentación automática no se aplica.
const sdk = new NodeSDK({
resource: new Resource({ [ATTR_SERVICE_NAME]: 'tasks-api',
[ATTR_SERVICE_VERSION]: process.env.APP_VERSION ?? 'dev' }),
traceExporter: new OTLPTraceExporter({ url: process.env.OTEL_ENDPOINT }),
// Automática: HTTP entrante y saliente, Express/Fastify, pg, redis, nest-core.
instrumentations: [getNodeAutoInstrumentations({
'@opentelemetry/instrumentation-fs': { enabled: false }, // demasiado ruido
'@opentelemetry/instrumentation-pg': { enhancedDatabaseReporting: true },
})],
// Muestreo: el 100% en un servicio con tráfico es carísimo. Este muestreador
// hereda la decisión del padre y, si no hay padre, toma el 10%.
sampler: new ParentBasedSampler({ root: new TraceIdRatioBasedSampler(0.1) }),
});
sdk.start();
// Instrumentación MANUAL: solo para lo que el SDK no puede saber, es decir,
// tus operaciones de negocio.
async close(projectId: string, userId: string) {
return tracer.startActiveSpan('project.close', async (span) => {
span.setAttributes({ 'project.id': projectId, 'user.id': userId });
try {
const p = await this.doClose(projectId, userId);
span.setAttribute('project.tasks_count', p.tasks.count());
return p;
} catch (e) {
span.recordException(e as Error); // el error, en la traza
span.setStatus({ code: SpanStatusCode.ERROR, message: (e as Error).message });
throw e;
} finally { span.end(); } // SIEMPRE: un span sin end() es una fuga de memoria
});
}13.16.3 Correlación: un hilo que atraviesa toda la petición
Tener los tres pilares no sirve de nada si viven en tres sistemas incomunicados. La escena habitual de una guardia mal preparada es esta: una alerta dice que el p99 se ha disparado; abres el panel de métricas y ves la subida; abres el sistema de logs y encuentras cuatro millones de líneas sin forma de saber cuáles pertenecen a las peticiones lentas; abres el visor de trazas y no sabes qué traza mirar. La información estaba toda ahí, y aun así el diagnóstico se hace a ciegas. Lo que falta no son datos: es una clave común que permita saltar de un pilar a otro.
Esa clave es el traceId. Un identificador de 128 bits que se genera en el borde de entrada, viaja en el contexto asíncrono de toda la petición, se estampa en cada línea de log, se propaga por la cabecera
traceparent a cualquier servicio invocado y se adjunta como ejemplar a las observaciones de los histogramas. Con eso, la investigación deja de ser una búsqueda y pasa a ser una navegación: del pico en la
gráfica se salta a una traza concreta que lo ejemplifica, y de esa traza a las cincuenta líneas de log de esa misma petición.
UN IDENTIFICADOR, TRES SISTEMAS
[1] Alerta: p99 de /projects/:id/tasks > 2 s ── métricas (Prometheus)
│ el histograma guarda un EJEMPLAR con el traceId de una petición lenta
▼
[2] Traza 4bf92f35…4736 ──────────────────────────── trazas (Jaeger/Tempo)
│ el árbol de spans muestra 40 SELECT idénticos: N+1 en TasksService
▼
[3] { "trace_id":"4bf92f35…", "level":50, "msg":"slow query", "ms":8 } ── logs
│ las 50 líneas de ESA petición, filtradas por trace_id
▼
[4] El usuario que abrió el ticket adjuntó la cabecera x-trace-id de su
respuesta: se investiga SU petición exacta, no una parecida.
SIN correlación: 3 búsquedas independientes por hora aproximada y suerte.
CON correlación: 3 clics. La diferencia son horas de incidente.
import { trace, context } from '@opentelemetry/api';
LoggerModule.forRoot({
pinoHttp: {
// mixin() se ejecuta en CADA línea y añade campos calculados. Lee el span
// activo del contexto de OpenTelemetry, que AsyncLocalStorage mantiene vivo
// a través de todos los await de la misma cadena asíncrona.
mixin() {
const span = trace.getSpan(context.active());
if (!span) return {}; // cron, arranque, consumidor
const { traceId, spanId } = span.spanContext();
// Nombres normalizados por OpenTelemetry: los backends de logs los
// reconocen y ofrecen el enlace directo a la traza. Inventarse el nombre
// (reqTraceId, tid...) rompe esa integración automática.
return { trace_id: traceId, span_id: spanId,
service: 'tasks-api', env: process.env.NODE_ENV };
},
},
});
// Y devolver el identificador al cliente: cuando alguien abra una incidencia,
// llegará con el dato exacto en lugar de "esta mañana no me funcionaba".
@Injectable()
export class TraceHeaderInterceptor implements NestInterceptor {
intercept(ctx: ExecutionContext, next: CallHandler) {
const traceId = trace.getSpan(context.active())?.spanContext().traceId;
if (traceId) ctx.switchToHttp().getResponse().setHeader('x-trace-id', traceId);
return next.handle();
}
}propagation.inject) como metadato del trabajo y restaurarlo en el consumidor (propagation.extract), o la traza se corta justo en la parte asíncrona, que es la más difícil de depurar. 2) Las
llamadas HTTP salientes hechas con un cliente no instrumentado. 3) Los setTimeout, setInterval y process.nextTick lanzados sin contexto y los pools de workers.
4) El balanceador o la pasarela de entrada, que puede generar su propio identificador y descartar el del cliente. Un ejercicio muy revelador: coger una petición cualquiera en producción y comprobar cuántos saltos
sobrevive su traceId.
13.16.4 Métricas de negocio, métricas técnicas y las cuatro señales doradas
Las cuatro señales doradas del libro Site Reliability Engineering de Google son el mínimo común denominador de cualquier servicio, y su valor está en que son pocas y suficientes: si solo puedes mirar cuatro gráficas, que sean estas.
| Señal | Qué mide | Cómo se instrumenta | Matiz que casi todo el mundo pasa por alto |
|---|---|---|---|
| Latencia | Cuánto tarda una petición | Histograma por ruta y método | Hay que separar la latencia de las peticiones correctas de la de las fallidas: un 500 instantáneo mejora tu p95 y hace que un incidente parezca una mejora de rendimiento |
| Tráfico | Demanda sobre el sistema | Contador de peticiones por segundo | Una caída brusca de tráfico es tan alarmante como una subida: casi siempre significa que algo aguas arriba ya no llega hasta ti |
| Errores | Fracción de peticiones fallidas | Contador con etiqueta de estado | Los fallos silenciosos —un 200 con el cuerpo vacío, un pago que no se registra— no aparecen aquí. Solo las métricas de negocio los ven |
| Saturación | Cuán lleno está el recurso más limitado | Gauge: pool de conexiones, memoria, cola de trabajos, retraso del bucle de eventos | Es la única predictiva: avisa antes de que el usuario note nada. Requiere saber cuál es tu recurso escaso, y en Node casi nunca es la CPU |
Junto a estas viven las métricas de negocio, y la distinción es más importante de lo que parece. Las técnicas describen el comportamiento del software; las de negocio describen el comportamiento del producto. Un despliegue que introduce un fallo en la validación de la pasarela de pagos puede dejar las cuatro señales doradas impecables —todas las peticiones responden 200 en 40 ms— mientras la facturación cae a cero. Ninguna alerta técnica se dispara, porque técnicamente no hay nada roto. La única señal es que han dejado de ocurrir cosas que siempre ocurren.
// Métricas de NEGOCIO: contadores de eventos con significado para el producto.
makeCounterProvider({ name: 'taskflow_tasks_created_total', help: 'Tareas creadas',
labelNames: ['plan'] }); // baja cardinalidad: free|pro|team
makeCounterProvider({ name: 'taskflow_projects_closed_total', help: 'Proyectos cerrados' });
makeCounterProvider({ name: 'taskflow_signups_total', help: 'Altas', labelNames: ['origen'] });
makeHistogramProvider({ name: 'taskflow_report_generation_seconds',
help: 'Duración de la generación de informes', buckets: [1, 5, 15, 30, 60, 120] });
// Se incrementan en el punto donde el hecho de negocio OCURRE de verdad, es
// decir, después de confirmar la transacción y no antes de intentarla.
async close(projectId: string, userId: string): Promise<Project> {
const project = await this.em.transactional(/* ... */);
this.proyectosCerrados.inc();
return project;
}
// La alerta que ninguna métrica técnica puede dar:
// - alert: SinAltasEnUnaHora
// expr: sum(increase(taskflow_signups_total[1h])) == 0
// and hour() > 7 and hour() < 23 # de madrugada el cero es normal
// for: 15m
// Detecta el formulario roto, el correo de verificación que no sale y el
// despliegue que rompió el registro sin devolver un solo error 5xx.projectId y tendrás millones. El resultado es un servidor de Prometheus que consume toda la memoria y muere, y con él la observabilidad completa, justo el día que más falta
hace. Regla: las etiquetas describen categorías con valores contados y estables (ruta con parámetros, método, código de estado, plan, región), nunca identidades. Lo que necesita identidad va a los logs y a las
trazas, que están diseñados para alta cardinalidad; las métricas, por definición, son agregados.
13.16.5 SLI, SLO, presupuesto de error y qué merece una alerta
Sin un objetivo declarado, la pregunta «¿va bien el sistema?» no tiene respuesta, solo opiniones. El vocabulario preciso ayuda a evitar discusiones circulares. Un SLI (indicador de nivel de servicio) es una medida
concreta de la experiencia del usuario, expresada como fracción de eventos buenos sobre eventos totales; por ejemplo, «peticiones a /api/v1/tasks con estado distinto de 5xx y latencia inferior a 300 ms, dividido
entre el total». Un SLO es el objetivo que fijas para ese indicador en una ventana temporal: «99,9% en 30 días». Un SLA es un SLO con consecuencias contractuales, y por eso siempre se pacta más laxo que el SLO
interno: quieres enterarte tú antes de que se entere el cliente.
De ahí sale la idea más útil de todo el marco: el presupuesto de error. Si el objetivo es 99,9%, tienes derecho a un 0,1% de fallos, y ese margen es un recurso que se gasta. Deja de ser un debate moral —«¿es aceptable que falle?»— para convertirse en aritmética: si en la primera semana del mes te has gastado el ochenta por ciento del presupuesto, la decisión de congelar las novedades y dedicar el sprint a la fiabilidad ya no la impone nadie, la impone el dato.
| SLO de disponibilidad | Presupuesto al mes | Presupuesto al año | Qué implica en la práctica |
|---|---|---|---|
| 99% («dos nueves») | 7 h 18 min | 3,65 días | Suficiente para una herramienta interna. Permite mantenimientos con parada |
| 99,9% («tres nueves») | 43 min 50 s | 8,76 h | Objetivo razonable para un SaaS como TaskFlow. Exige despliegues sin parada y reintentos |
| 99,95% | 21 min 54 s | 4,38 h | Un solo incidente mal gestionado consume el mes entero. Exige guardias reales |
| 99,99% («cuatro nueves») | 4 min 23 s | 52,6 min | Ninguna intervención humana llega a tiempo: obliga a redundancia multizona y recuperación automática. Multiplica el coste |
Con el presupuesto definido, la alerta correcta no es «hay errores» sino «el presupuesto se está consumiendo demasiado rápido». Esa tasa de consumo se llama burn rate: un valor de 1 significa que agotarás el presupuesto justo al final de la ventana; un valor de 14,4 significa que lo agotarás en dos días. La práctica recomendada combina dos ventanas: una corta que reacciona deprisa y una larga que evita el falso positivo de un pico de treinta segundos. Y cada alerta debe tener una severidad honesta: solo despierta a alguien lo que un ser humano puede y debe arreglar ahora mismo.
| Merece una alerta | Por qué | No merece una alerta | Por qué |
|---|---|---|---|
| Consumo del presupuesto de error a 14,4× durante 5 min | Síntoma que el usuario ya está sufriendo; queda margen para actuar | CPU al 85% | Si la latencia cumple el SLO, es eficiencia. Va al panel, no al buscapersonas |
| p99 de checkout por encima del SLO 10 min seguidos | Afecta a ingresos y no se recupera solo | Un pod reiniciado | El sistema se recuperó: es el mecanismo funcionando. Solo alerta el reinicio repetido |
| Cero altas o cero pagos en horario comercial | Fallo silencioso invisible a las métricas técnicas | Un error 500 aislado | Con mil peticiones por minuto, uno es ruido. Alerta la tasa, no el evento |
| Cola de trabajos creciendo sin parar 15 min | Saturación: predice la caída antes de que ocurra | Latencia alta en /metrics o /health | No los usa ningún cliente |
| Certificado que caduca en 14 días | Accionable, con margen y con final conocido | Cualquier umbral cuya respuesta sea «mirar y volver a dormir» | Es la definición de fatiga de alertas: la alerta se silencia y con ella la que sí importaba |
13.16.6 Health checks con Terminus
Un health check responde a una pregunta binaria e inmediata que hace el orquestador para decidir si reiniciar el contenedor o si mandarle tráfico; una métrica describe una tendencia para que la interpretes tú. No son sustitutos. La distinción crítica es entre liveness («¿está el proceso vivo? si no, reiníciame») y readiness («¿puedo atender tráfico? si no, sácame del balanceador pero no me mates»).
@Controller('health')
export class HealthController {
constructor(private health: HealthCheckService, private db: MikroOrmHealthIndicator,
private disk: DiskHealthIndicator, private mem: MemoryHealthIndicator) {}
// LIVENESS: sin dependencias externas. Si comprobaras la base de datos aquí,
// una caída de la base de datos provocaría el reinicio en bucle de TODAS las
// instancias sanas: un fallo parcial convertido en caída total.
@Get('live') @HealthCheck()
live() { return this.health.check([]); }
// READINESS: sí comprueba dependencias, con timeout corto y cacheado para no
// convertir el propio check en carga (lo llaman cada pocos segundos).
@Get('ready') @HealthCheck()
ready() {
return this.health.check([
() => this.db.pingCheck('database', { timeout: 1500 }),
() => this.mem.checkHeap('memory_heap', 512 * 1024 * 1024),
() => this.disk.checkStorage('disk', { path: '/', thresholdPercent: 0.9 }),
]);
}
}- alert: CPUAlta
expr: cpu_usage > 80
# ¿Y qué? Si la latencia es buena, la CPU alta es
# eficiencia, no un problema. Alerta a las 4:00 para
# que alguien mire un gráfico y se vuelva a la cama.
- alert: PodReiniciado
expr: increase(kube_pod_restarts[10m]) > 0
# El sistema se recuperó solo: eso es que FUNCIONA.
# Resultado: 40 alertas al día, ninguna accionable, y el
# equipo silencia el canal. Cuando llega la que importa,
# nadie la ve. Es la fatiga de alertas.# SLO: 99,9% de peticiones sin error 5xx en 30 días.
# Presupuesto de error: 0,1% ≈ 43 min/mes.
- alert: PresupuestoDeErrorConsumiendoseRapido
expr: |
(sum(rate(http_requests_total{status=~"5.."}[1h]))
/ sum(rate(http_requests_total[1h]))) > 0.001 * 14.4
for: 5m
# 14,4× el ritmo permitido: a este paso se agota el
# presupuesto del mes en 2 días. Síntoma que el
# USUARIO nota, y por tanto merece despertar a alguien.
labels: { severity: page }
- alert: LatenciaP95FueraDeSLO
expr: histogram_quantile(0.95,
sum(rate(http_request_duration_seconds_bucket[5m]))
by (le)) > 0.5
for: 10m
labels: { severity: ticket } # no despierta a nadie13.17 Debugging en el backend
Depurar es formular hipótesis y descartarlas con evidencia. Las herramientas cambian mucho según dónde esté el problema: en local se puede parar el mundo y mirar dentro; en producción no se puede parar nada y hay que observar desde fuera sin alterar el sistema. Conviene dominar las dos situaciones, porque los bugs más caros solo se manifiestan en la segunda.
13.17.1 Depuración local: inspector, perfilado y SQL generado
# 1· Aplicación con inspector y recarga (el starter de Nest ya lo trae).
nest start --debug --watch # abre 127.0.0.1:9229
# 2· Depurar TESTS. Sin --runInBand los breakpoints caen en otro proceso.
node --inspect-brk node_modules/.bin/jest --runInBand --testPathPattern=projects
# 3· En Docker: hay que escuchar en 0.0.0.0, no en localhost, y publicar 9229.
# --inspect-brk PARA el proceso hasta que te conectas: así depuras el arranque.
node --inspect-brk=0.0.0.0:9229 dist/main.js # + ports: ["9229:9229"]
# 4· Perfilado de CPU: 30 s de muestreo en producción, coste ~5%.
kill -USR2 <pid> # o node --cpu-prof --cpu-prof-dir=/tmp dist/main.js
# El .cpuprofile se abre en la pestaña Performance de Chrome DevTools.
# 5· FUGAS DE MEMORIA: tres heap snapshots (arranque, 1 h, 2 h) y comparar
# la vista "Objects allocated between snapshots". Lo que crece y no baja
# tras el GC es tu fuga: casi siempre un Map/array global que solo se
# llena, un listener que se añade por petición, o un span sin end().
node --heapsnapshot-signal=SIGUSR2 --max-old-space-size=512 dist/main.jsEn el lado de MikroORM, debug: ['query', 'query-params'] imprime cada consulta con sus parámetros: imprescindible para ver el SQL que genera realmente tu QueryBuilder y para descubrir un N+1. Actívalo
solo en desarrollo o de forma temporal, porque los parámetros pueden contener datos personales. Y para VS Code, un launch.json con "type": "node", "request": "attach",
"port": 9229 y "restart": true se reengancha solo tras cada recarga.
13.17.2 Diagnosticar en producción sin poder poner un punto de interrupción
En producción el depurador está prohibido, y no por dogma: un breakpoint detiene el hilo del proceso, con lo que dejas de responder a todas las peticiones en curso, el health check falla, el orquestador te saca del balanceador y probablemente reinicie el contenedor, destruyendo justo el estado que querías examinar. Además, exponer el puerto del inspector es un agujero de seguridad de primer orden: quien alcanza el puerto 9229 ejecuta código arbitrario dentro de tu proceso, con sus credenciales y su acceso a la base de datos. La depuración en producción es, por tanto, un conjunto de técnicas distintas, todas basadas en el mismo principio: extraer información sin detener el servicio.
| Síntoma | Herramienta | Coste sobre el servicio | Qué te dice |
|---|---|---|---|
| «Falla para algunos usuarios y no sé por qué» | Subir el nivel de log en caliente, acotado y temporal | Volumen de logs y algo de E/S; nulo si se acota | El camino exacto que siguió el código, con sus datos |
| «El p99 es malo pero no sé dónde se va el tiempo» | Muestreo de trazas dirigido o basado en la cola | 2–5% de latencia | El reparto del tiempo entre capas y la consulta culpable |
| «La memoria sube y nunca baja» | Instantáneas del montículo comparadas | Alto: pausa el proceso durante la captura | Qué tipo de objeto crece y quién lo retiene |
| «La CPU está al 100% sin más tráfico» | Perfilado de CPU por muestreo | ~5% durante la captura | La función concreta que consume los ciclos |
| «Todo va lento pero la CPU está baja» | Medición del retraso del bucle de eventos | Prácticamente nulo | Si hay trabajo síncrono bloqueando el hilo o es espera de E/S |
| «El proceso murió y no dejó nada» | Informe de diagnóstico de Node | Nulo hasta que se dispara | Pilas, versiones, límites de recursos y estado del heap en el momento del fallo |
@Controller('admin/diagnostics')
@UseGuards(JwtAuthGuard, RolesGuard) @Roles(Role.Admin) // NUNCA sin proteger
export class DiagnosticsController {
constructor(@InjectPinoLogger() private readonly logger: PinoLogger) {}
/**
* Sube el nivel de log sin redesplegar y lo devuelve solo con un temporizador.
* Lo segundo es lo importante: el 'debug' que alguien activó durante una
* incidencia y olvidó apagar multiplica por cincuenta la factura de logs y
* entierra las líneas útiles. La caducidad convierte el olvido en imposible.
*/
@Post('log-level')
cambiarNivel(@Body() dto: { level: Level; durationSec?: number }) {
const anterior = this.logger.logger.level;
this.logger.logger.level = dto.level;
const ms = Math.min(dto.durationSec ?? 300, 1800) * 1000; // 30 min como techo
setTimeout(() => { this.logger.logger.level = anterior; }, ms).unref();
return { level: dto.level, revierteA: anterior, enSegundos: ms / 1000 };
}
/** Perfilado de CPU bajo demanda: N segundos de muestreo y a descargar. */
@Post('cpu-profile')
async perfilar(@Body() dto: { seconds?: number }) {
const session = new inspector.Session();
session.connect();
// El muestreador toma pilas cada microsegundo; NO instrumenta el código,
// por eso el coste es bajo y se puede usar con tráfico real.
await post(session, 'Profiler.enable');
await post(session, 'Profiler.start');
await new Promise((r) => setTimeout(r, Math.min(dto.seconds ?? 20, 60) * 1000));
const { profile } = await post(session, 'Profiler.stop');
session.disconnect();
const ruta = `/tmp/cpu-${Date.now()}.cpuprofile`; // se abre en Chrome DevTools
await writeFile(ruta, JSON.stringify(profile));
return { ruta };
}
/** Instantánea del montículo: útil y CARA. Ver el aviso siguiente. */
@Post('heap-snapshot')
heapSnapshot() { return { ruta: v8.writeHeapSnapshot(`/tmp/heap-${Date.now()}.heapsnapshot`) }; }
}writeHeapSnapshot() fuerza una recolección de basura completa y detiene el hilo principal
mientras recorre el grafo de objetos: con un heap de dos gigabytes son varios segundos sin atender ni una sola petición, además de un fichero del tamaño del heap escrito en disco. Reglas de uso: sácala de una
sola instancia y quítala antes del balanceador; hazlo en horario de bajo tráfico; y comprueba que el disco tiene espacio, porque llenar el volumen durante una investigación convierte un problema de memoria en una caída
total. La alternativa barata para detectar la fuga es la métrica nodejs_heap_size_used_bytes: si tras cada recolección el suelo sube en escalera en lugar de volver al mismo nivel, hay fuga; la instantánea
solo hace falta para saber de qué.
import { monitorEventLoopDelay } from 'node:perf_hooks';
// Node es monohilo: si una función síncrona tarda 300 ms, TODAS las peticiones
// en vuelo esperan 300 ms, aunque la CPU esté al 20% y la base de datos ociosa.
// Ese retraso no aparece en ninguna métrica de latencia por endpoint: aparece
// repartido y homogéneo en todos, que es justo lo que despista.
const histograma = monitorEventLoopDelay({ resolution: 20 });
histograma.enable();
setInterval(() => {
lagP99.set(histograma.percentile(99) / 1e6); // nanosegundos → milisegundos
lagMedia.set(histograma.mean / 1e6);
histograma.reset();
}, 10_000).unref();
// Interpretación: < 10 ms sano · 10–50 ms carga alta · > 100 ms hay trabajo
// síncrono bloqueando: JSON.parse de cargas enormes, bcrypt con coste alto en
// el hilo principal, expresiones regulares con retroceso catastrófico, bucles
// sobre colecciones grandes, o serialización de respuestas de megabytes.
// La solución no es "optimizar un poco": es sacar ese trabajo del hilo
// principal (worker_threads, cola, streaming) o trocearlo.Para el muestreo de trazas en investigación hay dos técnicas que se complementan. La primera es el muestreo dirigido: aceptar una cabecera que fuerce el muestreo de una petición concreta, de modo que soporte pueda pedirle a un usuario afectado que reproduzca el problema y obtener su traza completa aunque el muestreo general esté al uno por ciento. La segunda es el muestreo basado en la cola, en el que la decisión se toma en el collector cuando la traza ya ha terminado y por tanto se sabe si fue lenta o si contenía un error; con él se conserva el cien por cien de las trazas interesantes y una fracción mínima de las aburridas. Es más caro de operar, porque el collector debe retener las trazas en memoria hasta decidir, pero resuelve la paradoja del muestreo aleatorio: con un uno por ciento, la petición que falló tiene un noventa y nueve por ciento de probabilidades de no estar grabada.
--report-on-fatalerror --report-on-signal --report-uncaught-exception --report-directory=/var/log/reports hace que Node escriba un fichero JSON con las pilas de todos los hilos, el uso de memoria,
los identificadores activos, las variables de entorno y los límites del sistema cuando el proceso muere de forma anómala o cuando le envías SIGUSR2. Es lo único que queda cuando un contenedor desaparece por
OOM killer y el orquestador solo informa de un lacónico Exit code 137. Cuesta cero mientras no se dispara y ahorra el escenario más frustrante de todos: un fallo que ocurre una vez a la semana y no deja
rastro alguno.
13.18 CI: ejecutar la suite en GitHub Actions
name: CI
on: { pull_request: {}, push: { branches: [main] } }
concurrency: # cancela ejecuciones obsoletas del mismo PR
group: ci-${{ github.ref }}
cancel-in-progress: true
jobs:
estatico:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with: { node-version: 22, cache: npm } # caché de ~/.npm por package-lock
- run: npm ci
- run: npx tsc --noEmit # los tipos, aparte de SWC
- run: npx eslint . --max-warnings=0
- run: npx mikro-orm migration:check # ¿modelo y migraciones cuadran?
tests:
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix: { shard: [1, 2, 3, 4] } # 4 procesos: la suite tarda 1/4
services:
postgres:
image: postgres:16-alpine
env: { POSTGRES_PASSWORD: test, POSTGRES_DB: test }
ports: ['5432:5432']
# SIN health-cmd, los tests arrancan antes que la base de datos:
# fallo intermitente clásico en CI y muy difícil de reproducir.
options: >-
--health-cmd "pg_isready -U postgres" --health-interval 5s
--health-timeout 5s --health-retries 10
env:
DATABASE_URL: postgres://postgres:test@localhost:5432/test
TZ: UTC # zona horaria fija: evita fallos que solo ocurren en CI
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with: { node-version: 22, cache: npm }
- run: npm ci
- run: npx jest --shard=${{ matrix.shard }}/4 --coverage --maxWorkers=2
- uses: actions/upload-artifact@v4
if: always() # los informes del intento FALLIDO son los útiles
with: { name: coverage-${{ matrix.shard }}, path: coverage/ }
# Job requerido en la protección de rama: "no se mergea en rojo". La regla es
# social antes que técnica; la protección de rama solo la hace cumplir.
todo-verde:
needs: [estatico, tests]
runs-on: ubuntu-latest
steps: [{ run: echo "Listo para mergear" }]13.19 Errores comunes y solución
| Error | Síntoma | Causa | Solución |
|---|---|---|---|
| Handles abiertos | Jest did not exit one second after... | Conexión del ORM, servidor, setInterval o consumidor sin cerrar | --detectOpenHandles para localizarlo y orm.close(true) más app.close() en afterAll. Nunca --forceExit como cura |
| Base de datos compartida en paralelo | Falla al ejecutar toda la suite y pasa en solitario | Cuatro workers escribiendo y truncando la misma base de datos | Un esquema o base de datos por JEST_WORKER_ID; --runInBand como parche temporal |
| E2E sin configuración global | El e2e pasa y producción devuelve 400 o 500 | createNestApplication() no aplica lo que hace main.ts | Extraer configureApp(app) y llamarla desde ambos sitios (13.10) |
| Mocks que ocultan errores | Cobertura del 95% y fallos en el primer despliegue | El mock devuelve lo que tú supones, no lo que hace la base de datos | Tests de integración reales para todo el acceso a datos; el mock, para la lógica |
| Identity Map | El test verifica un cambio que nunca se persistió | findOne devuelve el objeto cacheado, no una fila | em.clear() o em.fork() entre escribir y leer |
transactional mockeado en vacío | El test pasa sin ejecutar la lógica | jest.fn() devuelve undefined y el callback no corre | jest.fn(async (cb) => cb(em)) |
| Suites lentas | Cada archivo tarda 10 s en arrancar | Se importa AppModule entero para probar una función pura | Módulos de test mínimos; AppModule solo en los e2e; SWC en lugar de ts-jest |
| Flakiness por fechas | Falla el día 1, en fin de mes o al cambiar la hora | new Date() y zonas horarias del runner | TZ=UTC, reloj inyectado o jest.useFakeTimers({ now }), y faker.seed() |
| Un contenedor por archivo | La suite de integración tarda minutos y Docker se satura | El beforeAll se ejecuta una vez por proceso, y Jest crea uno por archivo | Arrancar en globalSetup y pasar la URL por process.env; .withReuse() en local (13.7.4) |
| Reversión inútil en e2e | El test crea un usuario y la petición HTTP devuelve 401 | La petición usa otro em.fork() y otra conexión: no ve la transacción abierta | Truncado en lugar de reversión para todo lo que pase por HTTP (13.7.5) |
| Temporizadores falsos con E/S real | El test se cuelga hasta agotar el timeout de Jest | useFakeTimers() congela también los temporizadores internos del driver y las microtareas | doNotFake: ['nextTick', 'setImmediate']; y falsear el tiempo solo en unitarios, con Clock en integración |
advanceTimersByTime sin await | El test se queda esperando una promesa que nunca resuelve | La versión síncrona avanza el reloj pero no vacía la cola de microtareas | await jest.advanceTimersByTimeAsync(ms) o runOnlyPendingTimersAsync() |
| Actualización perdida | Dos operaciones concurrentes y solo se guarda una | Sin columna de versión, el segundo UPDATE machaca al primero sin avisar | @Property({ version: true }), traducir OptimisticLockError a 409 y un test con dos fork() (13.8.3) |
| Consumidor de cola no idempotente | Correos, cargos o informes duplicados en producción | La entrega de una cola es «al menos una vez»: un reintento reprocesa el trabajo | jobId estable, clave de idempotencia en base de datos y un test que procese el mismo trabajo dos veces |
| Explosión de cardinalidad | Prometheus consume toda la memoria y muere | Etiquetas con identificadores o URLs completas: una serie temporal por valor | Etiquetar con la ruta parametrizada y categorías acotadas; la identidad va a logs y trazas (13.16.4) |
| Traza cortada en la cola | La traza termina en «trabajo encolado» y el consumidor aparece huérfano | El contexto de OpenTelemetry no se serializa solo al cruzar el proceso | propagation.inject en los metadatos del trabajo y extract en el consumidor (13.16.3) |
| Prueba de carga engañosa | Números excelentes en el ensayo y colapso el día del lanzamiento | Base de datos vacía, cliente en la misma máquina, sin calentamiento u omisión coordinada | Volumen realista, generador de carga separado, descartar el primer minuto y modelar picos con arrival-rate (13.14.3) |
| Esquema OpenAPI desactualizado | El frontend compila y falla en ejecución | El esquema se generó a mano o hace tres versiones | Generarlo en CI desde los decoradores, versionarlo y comparar con oasdiff en cada PR (13.13.2) |
debug olvidado en producción | La factura de logs se multiplica y no se encuentra nada | Alguien subió el nivel durante una incidencia y nadie lo bajó | Cambio de nivel con caducidad automática y techo máximo (13.17.2) |
13.20 Buenas y malas prácticas
Hazlo así
- Nombra los tests describiendo el comportamiento esperado, no el método: «lanza 403 si no es el propietario».
- Un concepto por test, con las tres fases visibles (preparar, actuar, comprobar).
- Verifica el estado observable y los efectos en la base de datos antes que las llamadas a mocks.
- Empieza cada bug por un test rojo que lo reproduzca.
- Cubre los casos límite con
it.each: es donde viven los bugs y donde una tabla cabe en cinco líneas. - Extrae la configuración global a una función compartida entre
main.tsy los tests. - Inyecta el reloj, el generador de ids y los clientes HTTP: lo que no se inyecta, no se testea.
- Mide cobertura de ramas, con umbral exigente en el dominio y laxo en el resto.
- Trata un test intermitente como un bug de prioridad alta: o se arregla o se borra.
- Registra siempre identificadores estructurados y un
requestIdpropagado. - Alerta sobre síntomas que el usuario nota y define un SLO con presupuesto de error.
- Arranca un solo contenedor por ejecución y aísla por esquema o base de datos, no por serialización.
- Prueba la atomicidad forzando el fallo en el punto más tardío de la operación y verificando desde otro
EntityManager. - Haz idempotentes los consumidores de cola y demuéstralo procesando el mismo trabajo dos veces.
- Genera el cliente del frontend desde el esquema OpenAPI para que el compilador detecte las rupturas de contrato.
- Estampa el
trace_iden cada línea de log y devuélvelo en una cabecera de respuesta. - Instrumenta métricas de negocio: son las únicas que detectan los fallos silenciosos.
Evita esto
- Perseguir el 100% de cobertura: incentiva tests vacíos de getters y da falsa confianza.
- Tests que dependen del orden de ejecución o de datos que dejó otro archivo.
- Un
expectpor cada llamada interna: eso congela la implementación, no el contrato. - Lógica en los tests (bucles,
if, cálculos): si el test tiene bugs, ¿quién lo testea? --forceExitpara tapar recursos sin cerrar.- Sustituir los guards en toda la suite e2e: dejas la seguridad sin probar.
- Fixtures gigantes compartidos y crecientes.
- Mockear lo que no es tuyo sin un test de integración que valide la suposición.
console.logen producción, y volcar objetos enteros de petición o de usuario en los logs.- Etiquetas de métricas con ids o urls completas: explosión de cardinalidad.
- Alertar sobre CPU, memoria o reinicios sin relación con el usuario.
- Comprobar la base de datos en el liveness: convierte un fallo parcial en una caída total.
- Sustituir PostgreSQL por SQLite en los tests de una aplicación que en producción habla PostgreSQL.
- Afirmar que algo es transaccional comprobando que se llamó a
transactional. - Congelar el tiempo con temporizadores falsos mientras hay conexiones reales abiertas.
- Esperar a un trabajo asíncrono con un
setTimeoutarbitrario en lugar de con su evento de finalización. - Medir el rendimiento con la media, con la base de datos vacía o desde la misma máquina que sirve la API.
- Exponer el puerto del inspector de Node fuera de tu equipo: equivale a ejecución remota de código.
13.21 Preguntas frecuentes
¿Cuánta cobertura es suficiente?
La cobertura mide qué líneas se ejecutan, no si las verificas: un test sin ningún expect las cubre igual. Úsala como detector de huecos (abre el informe HTML y busca ramas rojas en la lógica crítica), no como
objetivo. Un reparto razonable: 90-95% en el dominio y los servicios, 70-80% global, y sin umbral en módulos, DTOs y bootstrap. Perseguir el 100% produce tests que solo suben el número.
¿Debo testear los controladores?
Casi nunca de forma unitaria, porque en un controlador correcto solo hay delegación y los decoradores no se ejecutan al instanciarlo con new. Prueba la capa HTTP con e2e o con el módulo mínimo de 13.6. La
excepción es un controlador con lógica propia, que además suele ser una señal de que esa lógica debería estar en un servicio.
¿SQLite en memoria o Testcontainers?
Testcontainers, salvo que tu acceso a datos sea CRUD trivial. SQLite parece más rápido y termina costando más: no tiene jsonb, ni ILIKE, ni tipos enum, ni bloqueos explícitos, y su
tipado dinámico y su ordenación de NULL difieren de PostgreSQL. Cada diferencia es un test que pasa en verde mientras producción falla, o al revés.
¿Cómo evito que la suite tarde diez minutos?
Mide primero con jest --detectSlowTests o revisando los tiempos por archivo. Las cuatro causas habituales: ts-jest sin isolatedModules (cambia a SWC), importar AppModule
en tests que no lo necesitan, crear el esquema en cada test en lugar de truncar, y arrancar un contenedor por archivo en vez de uno por ejecución. Después, paraleliza con --shard en CI.
¿Los tests e2e deben usar la base de datos real?
Sí: el mismo motor y versión que producción, en una base de datos dedicada y desechable. Lo que sí conviene sustituir son los servicios externos de pago —correo, pasarelas, APIs de terceros— porque son lentos, tienen coste
y no controlas su disponibilidad. Ese es exactamente el reparto que hace createTestApp() en 13.10.
¿Cómo testeo código que depende de la fecha actual?
Dos opciones. La limpia: inyectar un Clock ({ now(): Date }) y sustituirlo por uno fijo, que además documenta que el tiempo es una dependencia. La rápida:
jest.useFakeTimers({ now: new Date('2026-03-15T10:00:00Z') }), útil también para advanceTimersByTime con timeouts y reintentos. Y en CI, TZ=UTC siempre.
¿Pino o Winston?
Pino por defecto: es un orden de magnitud más rápido, serializa a JSON de forma nativa, tiene redacción integrada y nestjs-pino se integra con el Logger de Nest y con el contexto de petición.
Winston tiene sentido si necesitas su ecosistema de transports hacia destinos poco comunes; aun así, la práctica recomendada es escribir JSON a stdout y que el envío lo haga el agente del orquestador.
¿Qué nivel de log uso en producción?
info como norma, configurable por variable de entorno para poder subir a debug
temporalmente durante una incidencia sin redesplegar. Cuidado con dejar debug permanente: el coste del sistema de logs se multiplica y las líneas relevantes se pierden entre el ruido.
¿Es caro instrumentar con OpenTelemetry?
La instrumentación automática añade en torno a un 2-5% de latencia, dominado por la creación de spans. Se controla con muestreo (ParentBasedSampler con un 1-10% en la raíz), desactivando la instrumentación de
fs y exportando por lotes en un proceso collector aparte. Comparado con el tiempo que ahorra en la primera incidencia distribuida, es de las inversiones más rentables que existen.
¿Por qué mi liveness no debe comprobar la base de datos?
Porque si la base de datos cae, todas las instancias fallan el liveness a la vez y el orquestador las reinicia en bucle. Pierdes la capacidad de servir lo que no necesita base de datos, la aplicación tarda más en recuperarse cuando la base de datos vuelve y los logs del arranque tapan la causa real. Las dependencias van en readiness.
¿Cómo se testea un guard que usa AsyncLocalStorage?
Envuelve la llamada en RequestContext.run({ requestId: 'test-1' }, () => guard.canActivate(ctx)). Si el código lee el contexto y no encuentra nada, debe degradar con elegancia y no lanzar: eso mismo pasa en
producción en tareas programadas y consumidores de cola, donde no hay petición HTTP.
¿Merece la pena TDD en un backend con Nest?
Para lógica de dominio, mucho: escribir primero el test fuerza a diseñar la interfaz desde el punto de vista de quien la usa y evita el sesgo de escribir el test que el código ya pasa. Para código de infraestructura —una consulta con cinco joins, un mapeo de MikroORM— suele ser más productivo explorar en el REPL o con un test de integración iterativo y consolidar el test cuando el diseño se estabiliza.
¿Testcontainers o un servicio de PostgreSQL declarado en el workflow?
No son excluyentes y la respuesta correcta suele ser «los dos», porque resuelven problemas distintos. Testcontainers brilla en local: cada persona ejecuta la suite sin instalar nada y con la misma versión exacta del
motor. El servicio declarado en CI brilla en el runner: ya está arrancado, tiene comprobación de salud integrada y no depende de que haya un daemon de Docker accesible desde dentro del job. La clave es que
esa decisión no llegue a tus tests: si createTestOrm() lee la URL de una variable de entorno, cambiar de origen es una línea de configuración y ningún test se entera.
¿Cómo pruebo que una operación es atómica sin acabar reescribiendo su implementación en el test?
Verificando la definición de atomicidad en lugar del mecanismo. Fotografía el estado observable antes, provoca un fallo en el punto más tardío posible —lo ideal es que sea un colaborador real el que falle, o una
restricción de la base de datos— y comprueba que la fotografía posterior es idéntica, leyendo siempre desde un EntityManager nuevo. Ese test sigue siendo válido si mañana cambias em.transactional() por
un decorador, por una unidad de trabajo propia o por dos transacciones encadenadas: solo se rompe si de verdad pierdes la atomicidad, que es exactamente lo que un buen test debe hacer.
¿Merece la pena el contract testing en un equipo con un solo frontend?
Pact con su broker, probablemente no: introduce infraestructura y coordinación para un problema que aún no tienes. Pero la versión ligera sí, y su coste es casi nulo si ya documentas con Swagger: versiona el
openapi.json generado, compáralo con el de la rama principal en cada pull request para detectar cambios incompatibles y genera desde él los tipos del cliente Angular. Con eso el compilador del frontend se
convierte en tu verificador de contrato. Pact empieza a compensar cuando hay tres o más consumidores con calendarios de despliegue independientes.
¿Cada cuánto hay que ejecutar pruebas de carga y con qué datos?
Con tres cadencias distintas. Una prueba de humo de un minuto en cada pull request, cuyo único objetivo es cazar la regresión evidente —un N+1 recién introducido, un índice que alguien borró— comparando contra un valor de referencia. Una prueba de carga completa antes de cada versión importante o de cualquier cambio en el acceso a datos. Y una de resistencia y otra de picos antes de campañas, lanzamientos o fechas señaladas. Los datos deben ser representativos en volumen y en distribución: una tabla con cien filas hace que PostgreSQL elija escaneos secuenciales y no ejercita ni un índice, con lo que medirías un sistema que no existe.
¿Cómo se elige un SLO razonable y quién lo decide?
No lo decide el equipo técnico en solitario, porque es una decisión de producto con consecuencias de coste. El método que funciona: mide durante unas semanas el comportamiento real, comprueba si los usuarios se quejan a los niveles actuales y fija el objetivo ligeramente por encima de lo que ya cumples. Un SLO que incumples desde el primer día es ruido; uno que cumples con holgura absoluta no informa de nada. Y recuerda que cada nueve adicional multiplica el coste de infraestructura y de operación: la pregunta útil no es «¿cuánta disponibilidad queremos?» —todo el mundo quiere toda— sino «¿cuánto estamos dispuestos a pagar por el siguiente nueve?».
¿Puedo conectar un depurador a producción si tengo mucho cuidado?
No, y por dos motivos independientes. El técnico: un breakpoint detiene el hilo, así que dejas de responder, el health check falla y el orquestador reinicia el contenedor, llevándose el estado que querías examinar. El de seguridad, más grave: el protocolo del inspector no tiene autenticación, de modo que cualquiera que alcance ese puerto ejecuta código dentro de tu proceso con sus credenciales. Lo que sí puedes hacer es lo de 13.17.2: subir el nivel de log de forma temporal, capturar un perfil de CPU de veinte segundos, forzar el muestreo de una traza concreta o sacar una instantánea del montículo de una instancia retirada del balanceador.
¿Qué hago con un test que falla una vez de cada veinte?
Tratarlo como un bug de prioridad alta, nunca como una molestia que se resuelve reintentando. Un test intermitente hace dos daños: enseña al equipo a ignorar el rojo —y el día que el rojo es real, también se ignora— y suele
ser el síntoma de una condición de carrera real en el código de producción, no en el test. El procedimiento: reprodúcelo con jest --runTestsByPath ruta --repeat o con un bucle, busca las cuatro causas
habituales (orden entre archivos, tiempo, azar sin semilla y estado compartido en la base de datos) y, si en un día no lo arreglas, desactívalo con un enlace a la incidencia. Una suite con nueve tests fiables vale más
que una con diez de los que uno miente.
13.22 Ejercicios
Nivel 1 · Fundamentos
- Fábrica de mocks. Implementa
createMockEntityManager()de 13.4.2 en tu proyecto y escribe un test que demuestre quetransactionalejecuta el callback y quepersistes encadenable. - Reglas de negocio. Escribe la suite de un
TasksService.assign(taskId, userId)con estas reglas: la tarea existe, el usuario es miembro del proyecto, no se puede asignar una tarea cerrada y reasignar a la misma persona no es un error. Cubre los cuatro casos y comprueba tipo y mensaje de cada excepción. - Guard aislado. Testea un
ApiKeyGuardque leex-api-key: clave válida, ausente, inválida y con espacios. UsamockExecutionContexty no arranques la aplicación. - Reloj inyectado. Sustituye los
new Date()de un servicio de suscripciones por unClockinyectado y escribe conit.eachlos casos de renovación mensual que empiezan el 31 de enero, el 29 de febrero de un año bisiesto y el día del cambio de hora. Comprueba que el test falla si vuelves a la implementación anterior.
Nivel 2 · Intermedio
- Integración con SQL real. Monta
createTestOrm()con Testcontainers y escribe un test del métodosearch()de un repositorio con filtro combinado, paginación y ordenación conNULLS LAST. Añade una aserción que falle si aparece un N+1. - E2E honesto. Extrae
configureApp()de tumain.ts, úsala encreateTestApp()y escribe el flujo completo de 13.10. Comprueba que un usuario no puede tocar recursos de otro (403) y que la respuesta no filtrapasswordHash. - Aislamiento. Implementa
truncateAll()y demuestra con dos tests que el orden de ejecución no importa: ejecútalos con--shard=1/2y--shard=2/2. - Logging estructurado. Configura
nestjs-pinocon redacción yx-request-id. Escribe un test que haga una petición con una contraseña en el cuerpo y verifique que en la salida capturada aparece[REDACTADO]y no la contraseña. - Un contenedor para toda la suite. Parte de una suite con Testcontainers en el
beforeAllde cada archivo, mide su duración total, muévelo aglobalSetupcon un esquema porJEST_WORKER_IDy vuelve a medir. Documenta las dos cifras y explica de dónde sale la diferencia. - Limpieza comparada. Implementa las tres estrategias de 13.7.5 —reversión, truncado y clonado de plantilla— sobre el mismo archivo de veinte tests y compara sus tiempos. Después escribe un e2e por HTTP y demuestra con él por qué la reversión no sirve en ese caso.
- Contrato con el frontend. Genera el esquema OpenAPI desde tus decoradores, guárdalo en el repositorio y añade a CI un paso que falle ante un cambio incompatible. Comprueba que renombrar un campo de un DTO de respuesta rompe el pipeline y que añadir un campo opcional de entrada no lo rompe.
Nivel 3 · Avanzado
- Observabilidad completa. Añade métricas de Prometheus con el interceptor de 13.16.1, un span manual en una operación de negocio y
/health/livey/health/ready. Verifica en un e2e que/metricsexponehttp_requests_totalcon la etiquetaroutenormalizada. - Caza del N+1. Escribe un matcher propio
toUseAtMostQueries(n)que cuente las consultas del logger del ORM y aplícalo a tres endpoints de listado. - Fuga de memoria. Introduce a propósito un
Mapglobal que crezca por petición, reprodúcela con un bucle de mil peticiones, captura dos heap snapshots y localiza la fuga comparándolos. - CI completa. Monta el workflow de 13.18 con PostgreSQL como servicio, cuatro shards,
migration:checky protección de rama. Añade un job que falle si la cobertura del dominio baja del 90%. - Concurrencia real. Añade una columna de versión a una entidad de contador, escribe el test de conflicto con dos
EntityManager, comprueba que sin reintento se pierde un incremento y con reintento no, y traduce elOptimisticLockErrora un409verificado por un e2e. - Trabajo en cola de extremo a extremo. Publica un informe desde un endpoint que responda
202, procésalo con un worker y escribe los tres niveles de test de 13.8.4. Demuestra la idempotencia procesando el mismo trabajo dos veces y comprueba la rama del último intento fallido. - Prueba de carga con umbrales. Escribe un guion de k6 con un escenario de carga y otro de picos sobre el listado de tareas, con umbrales de p95 y de tasa de error. Ejecútalo con 20, 50, 100 y 200 usuarios, dibuja la curva de latencia frente a concurrencia, localiza la rodilla y explica con datos qué recurso la provoca.
- Correlación de los tres pilares. Estampa el
trace_iden los logs, devuélvelo en una cabecera y propágalo a un trabajo en cola. Provoca un error dentro del consumidor y demuestra que, partiendo de la cabecera devuelta al cliente, puedes llegar hasta la línea de log del worker.
Solución comentada · Ejercicio 2 (reglas de negocio)
describe('TasksService.assign', () => {
let service: TasksService; let em: MockEm;
const tarea = (over = {}) => ({ id: 't-1', status: TaskStatus.Todo, assignee: null,
project: { id: 'p-1', members: [{ id: 'u-1' }, { id: 'u-2' }] }, ...over }) as Task;
beforeEach(async () => {
em = createMockEntityManager();
service = (await Test.createTestingModule({ providers: [TasksService,
{ provide: EntityManager, useValue: em }] }).compile()).get(TasksService);
});
it('asigna la tarea a un miembro del proyecto', async () => {
em.findOne.mockResolvedValue(tarea());
const t = await service.assign('t-1', 'u-2');
expect(t.assignee.id).toBe('u-2');
expect(em.flush).toHaveBeenCalledTimes(1);
});
it('404 si la tarea no existe', async () => {
em.findOne.mockResolvedValue(null);
await expect(service.assign('t-x', 'u-1')).rejects.toThrow(NotFoundException);
expect(em.flush).not.toHaveBeenCalled(); // sin efectos: importa tanto como el error
});
it('403 si el usuario no es miembro del proyecto', async () => {
em.findOne.mockResolvedValue(tarea());
await expect(service.assign('t-1', 'u-9')).rejects.toThrow(ForbiddenException);
});
it('409 si la tarea ya está cerrada', async () => {
em.findOne.mockResolvedValue(tarea({ status: TaskStatus.Done }));
// ConflictException y no BadRequest: el problema es el ESTADO del recurso,
// no la forma de la petición. Ese matiz lo consume el cliente.
await expect(service.assign('t-1', 'u-2')).rejects.toThrow(ConflictException);
});
it('reasignar a la misma persona es idempotente y no escribe', async () => {
em.findOne.mockResolvedValue(tarea({ assignee: { id: 'u-2' } }));
await expect(service.assign('t-1', 'u-2')).resolves.toBeDefined();
// La optimización es parte del contrato: sin cambios, sin UPDATE ni evento.
expect(em.flush).not.toHaveBeenCalled();
});
});Solución comentada · Ejercicio 12 (matcher toUseAtMostQueries)
// test/matchers/queries.ts
export function withQueryCount<T>(orm: MikroORM) {
const consultas: string[] = [];
const anterior = orm.config.get('logger');
orm.config.set('debug', ['query']);
orm.config.set('logger', (msg: string) => { if (/^\[query]/.test(msg)) consultas.push(msg); });
return { consultas, restore: () => {
orm.config.set('debug', false); orm.config.set('logger', anterior); } };
}
expect.extend({
async toUseAtMostQueries(fn: () => Promise<unknown>, orm: MikroORM, max: number) {
const { consultas, restore } = withQueryCount(orm);
try { await fn(); } finally { restore(); }
const pass = consultas.length <= max;
return { pass, message: () => pass
? `Se esperaban más de ${max} consultas y hubo ${consultas.length}`
// El mensaje de fallo LISTA las consultas: sin eso, sabes que hay un N+1
// pero no cuál es, y el matcher deja de ser útil justo cuando lo necesitas.
: `Se esperaban ${max} consultas como máximo y hubo ${consultas.length}:\n`
+ consultas.map((q, i) => ` ${i + 1}. ${q.slice(0, 120)}`).join('\n') };
},
});
// Uso: el número es el CONTRATO de rendimiento del endpoint.
it('lista tareas con su proyecto y autor en 2 consultas', async () => {
await expect(() => service.findAll({ page: 1, limit: 50 }))
.toUseAtMostQueries(orm, 2);
});Solución comentada · Ejercicio 15 (concurrencia con bloqueo optimista y reintento)
// src/counters/counter.entity.ts
@Entity()
export class Contador {
@PrimaryKey() id!: string;
@Property() valor = 0;
// La columna de versión es TODO el mecanismo: MikroORM añade
// "AND version = ?" al UPDATE y comprueba las filas afectadas.
@Property({ version: true }) version!: number;
}
// src/counters/counters.service.ts
@Injectable()
export class CountersService {
constructor(private readonly em: EntityManager) {}
/**
* Reintento acotado. Tres decisiones deliberadas:
* 1) fork() NUEVO en cada intento: reutilizar el EM arrastraría la entidad
* con la versión caducada en la Identity Map y el reintento fallaría
* igual, para siempre. Es el error más común de este patrón.
* 2) espera creciente con jitter: sin el componente aleatorio, dos procesos
* en conflicto reintentan a la vez indefinidamente (contienda sincronizada).
* 3) límite de intentos: sin él, un conflicto permanente se convierte en un
* bucle infinito que consume una conexión del pool hasta agotarlo.
*/
async incrementar(id: string, intentos = 3): Promise<number> {
for (let i = 0; i < intentos; i++) {
const em = this.em.fork();
try {
const c = await em.findOneOrFail(Contador, id);
c.valor += 1;
await em.flush();
return c.valor;
} catch (e) {
if (!(e instanceof OptimisticLockError) || i === intentos - 1) {
// Agotados los reintentos, el conflicto es del CLIENTE: su copia
// está caducada y debe releer. 409, nunca 500.
if (e instanceof OptimisticLockError) {
throw new ConflictException('El recurso cambió mientras lo editabas');
}
throw e;
}
await sleep(2 ** i * 10 + Math.random() * 10);
}
}
throw new Error('inalcanzable');
}
}
// test/integration/counters.int-spec.ts
describe('CountersService.incrementar (concurrencia)', () => {
beforeEach(async () => {
await truncateAll(orm);
const em = orm.em.fork();
em.create(Contador, { id: 'c-1', valor: 0 });
await em.flush();
});
it('detecta el conflicto: la segunda escritura sobre la misma versión falla', async () => {
const emA = orm.em.fork(); const emB = orm.em.fork();
const a = await emA.findOneOrFail(Contador, 'c-1');
const b = await emB.findOneOrFail(Contador, 'c-1'); // misma versión que a
a.valor = 10; await emA.flush(); // version 1 → 2
b.valor = 20;
await expect(emB.flush()).rejects.toBeInstanceOf(OptimisticLockError);
// Sin versión, este flush habría escrito 20 machacando el 10 SIN ERROR.
expect((await orm.em.fork().findOneOrFail(Contador, 'c-1')).valor).toBe(10);
});
it('con reintento, diez incrementos concurrentes dan exactamente diez', async () => {
// Promise.all sobre el mismo bucle de eventos: se solapan de verdad.
// Este test es la demostración empírica de que no se pierde ninguno.
await Promise.all(Array.from({ length: 10 }, () => service.incrementar('c-1')));
expect((await orm.em.fork().findOneOrFail(Contador, 'c-1')).valor).toBe(10);
});
it('sin reintento se pierden incrementos (el bug que estamos previniendo)', async () => {
await Promise.all(Array.from({ length: 10 },
() => service.incrementar('c-1', 1).catch(() => null)));
// Documenta el contraejemplo: con un solo intento, varios conflictos
// acaban en excepción y el contador se queda por debajo de 10. Tener el
// test del fallo hace INDISCUTIBLE por qué existe el reintento.
expect((await orm.em.fork().findOneOrFail(Contador, 'c-1')).valor).toBeLessThan(10);
});
it('e2e: el conflicto irresoluble llega al cliente como 409 con mensaje útil', async () => {
jest.spyOn(service, 'incrementar')
.mockRejectedValue(new ConflictException('El recurso cambió mientras lo editabas'));
const { body } = await http().post('/api/v1/counters/c-1/increment').set(auth).expect(409);
// El contrato de error también es contrato: el frontend distingue "recarga
// y vuelve a intentarlo" (409) de "el servidor está roto" (500).
expect(body).toMatchObject({ statusCode: 409, message: expect.stringContaining('cambió') });
});
});13.23 Resumen del capítulo
- Testea lo que puede romperse de forma costosa: reglas de negocio, casos límite, autorización y contratos. No testees el framework ni los getters triviales.
- Sube de nivel solo cuando el inferior no puede responder. Unitario para la lógica, integración para las consultas, e2e para el contrato HTTP y los permisos.
- Los mocks validan tus suposiciones, no la realidad. Todo acceso a datos necesita al menos un test contra una base de datos real, y en MikroORM hay que contar con la Identity Map y con que
transactionaldebe ejecutar su callback. - Los e2e deben aplicar la misma configuración global que producción, extraída a una única función.
- El aislamiento no es opcional: truncado o transacción por test, un esquema por worker, reloj fijo, faker con semilla y
TZ=UTC. Un test intermitente destruye la confianza de todo el equipo. - La dificultad para testear es un diagnóstico de diseño, y casi siempre apunta a un principio SOLID incumplido.
- En producción solo sabes lo que instrumentaste: JSON estructurado, redacción de datos sensibles y un identificador de correlación propagado a logs, colas y llamadas salientes.
- Los tres pilares se complementan: las métricas dicen que algo va mal, las trazas dónde, los logs por qué. Y las alertas van sobre síntomas que el usuario nota, con SLO y presupuesto de error.
- Testcontainers convierte la infraestructura en una dependencia del test, pero solo si arrancas un contenedor por ejecución y aíslas por esquema. Y la estrategia de limpieza es una decisión con consecuencias: reversión para servicios y repositorios, truncado para todo lo que pase por HTTP.
- La atomicidad y la concurrencia solo se prueban provocándolas: forzando un fallo en el punto más tardío, abriendo dos
EntityManagersobre la misma versión y procesando dos veces el mismo trabajo de la cola. Son los bugs que no producen excepciones, sino datos incorrectos. - El tiempo y el azar son entradas ocultas: inyecta el reloj y el generador de identificadores, congela el tiempo solo en los unitarios y separa el «cuándo» del «qué» en las tareas programadas.
- El contrato con tus consumidores no lo verifica tu suite. Genera el esquema OpenAPI, versiónalo, detecta los cambios incompatibles en CI y convierte el esquema en los tipos del cliente para que sea el compilador quien avise.
- Las pruebas de carga responden a una pregunta que el código no contesta: dónde está la rodilla de la curva y qué recurso la provoca. Percentiles, tasa de error, caudal y saturación, siempre juntos.
- Sin correlación no hay observabilidad, solo datos: un
trace_iden cada log, en cada traza y en la respuesta al cliente convierte una investigación de horas en tres clics. - En producción se diagnostica sin detener nada: nivel de log dinámico con caducidad, perfilado por muestreo, retraso del bucle de eventos e informes de diagnóstico. El depurador se queda en local.
- El pipeline es el guardián: tipos, lint,
migration:check, tests con PostgreSQL real, cobertura y protección de rama. En rojo no se mergea.
13.24 Recursos adicionales
- Documentación oficial de Nest: Testing, Logger y Terminus (health checks).
- Jest: configuración y CLI (
--shard,--detectOpenHandles). - MikroORM: guía oficial, Identity Map y seeding.
- Testcontainers para Node: módulo de PostgreSQL y ciclo de vida y reutilización de contenedores.
- Concurrencia y colas: transacciones y bloqueos en MikroORM, niveles de aislamiento de PostgreSQL y documentación de BullMQ.
- Contratos: OpenAPI en NestJS, openapi-typescript, oasdiff para detectar cambios incompatibles y Pact para contratos dirigidos por el consumidor.
- Rendimiento: documentación de k6 (ejecutores, umbrales y escenarios) y autocannon.
- Diagnóstico en Node: informes de diagnóstico,
perf_hooksy el retraso del bucle de eventos y guía oficial de diagnóstico de memoria. - Datos generados: fast-check para property-based testing.
- Observabilidad: OpenTelemetry para JavaScript, histogramas y percentiles en Prometheus, redacción en Pino, propagación de contexto en OpenTelemetry, las cuatro señales doradas del SRE Book y el capítulo de alertas sobre SLO del SRE Workbook de Google.
- Libros: xUnit Test Patterns (Meszaros) para el vocabulario de dobles; Unit Testing (Khorikov) para distinguir tests valiosos de tests frágiles; Observability Engineering (Majors, Fong-Jones, Miranda) para la parte de instrumentación.