Parte III · NestJS

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ó.

NEST CORE Tiempo de lectura: ~105 min Prerrequisitos: capítulos 9 a 12 (módulos, providers, MikroORM, autenticación)

13.1 Qué vas a poder hacer al terminar

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

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.

Analogía: la inspección técnica de un vehículo La ITV no desmonta el motor para verificar que cada pistón sube y baja. Comprueba síntomas observables desde fuera: frena en la distancia debida, las luces funcionan, la dirección no tiene holgura. Un buen test se parece a esto. Un test que espía llamadas internas se parece más a poner una cámara en el cigüeñal: mucha información, ninguna garantía, y la cámara se cae en cuanto cambias el motor.

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)
NivelQué verificaCosteQué NO detecta
EstáticoCoherencia interna, contratos de tipos, promesas sin awaitCasi nulo; corre al guardarNada del comportamiento en ejecución ni de los datos externos
UnitarioLógica de negocio, ramas condicionales, errores lanzados, casos límiteMuy bajo: sin E/SErrores de SQL, de esquema, de serialización, de orden de los pipes
IntegraciónConsultas, relaciones, restricciones, transacciones, migraciones, mapeoMedio: exige esquema y limpiezaLa capa HTTP: enrutado, validación, guards globales, filtros
E2E HTTPEl sistema como lo ve un cliente: estado, cuerpo, cabeceras, efectos, permisosAlto: arranca la aplicaciónRamas internas raras; y la causa exacta («algo falló», no «dónde»)
El antipatrón del cono de helado Es la pirámide invertida: cientos de tests e2e lentos y frágiles y casi ningún test unitario. Síntomas: la suite tarda cuarenta minutos, falla aleatoriamente una vez de cada cinco y nadie sabe por qué, porque el único mensaje es 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.

package.jsonINCORRECTO
{
  "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.
jest.config.tsCORRECTO
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;
Detalle real de 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.

.swcrc · imprescindible para Nest y MikroORM
{
  "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' }
Por qué 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

tasks.e2e-spec.tsINCORRECTO
// "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.
tasks.e2e-spec.tsCORRECTO
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

MecanismoCuándo usarloEjemplo
useValueLo habitual: un objeto literal con jest.fn(); control total y aserciones sobre llamadas{ provide: MailService, useValue: { send: jest.fn() } }
useClassCuando el doble tiene comportamiento reutilizable: un repositorio en memoria, un reloj fijo{ provide: Clock, useClass: FixedClock }
useFactoryCuando 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 })
Los 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étodoQué debe hacer el doble
find, findAllResolver a un array de entidades (por defecto, vacío)
findOneResolver a la entidad o a null (nunca undefined)
findOneOrFailResolver, o rechazar con NotFoundError
findAndCount, countResolver a la tupla [entidades, total] y al número
createDevolver una instancia nueva sin tocar la base de datos
assignMutar y devolver la misma entidad
persist, removeDevolver el propio EM: la API es fluent y se encadena
flush, persistAndFlush, removeAndFlushResolver a void: aquí se ejecutaría el SQL real
nativeDeleteResolver al número de filas afectadas
transactionalInvocar el callback pasándole un EM (ver aviso)
getReference, fork, clearReferencia por id; otro EM (en el doble, él mismo); vaciar la caché
El error más frecuente: 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)).
test/mocks/mikro-orm.mock.ts · fábrica reutilizable
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 }
Alternativa: @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.

src/projects/projects.service.ts
@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;
  }
}
src/projects/projects.service.spec.ts
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();
  });
});
aserciones de errorINCORRECTO
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();
});
aserciones de errorCORRECTO
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');
  }
});
Por qué importa el tipo de la excepción y no solo el mensaje En Nest el tipo determina el código de estado HTTP. Confundir 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.

test/utils/execution-context.mock.ts
/** 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)) });
src/auth/roles.guard.spec.ts
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]);
  });
});
src/common/pipes/parse-slug.pipe.spec.ts
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);
    });
});
src/common/interceptors/timeout.interceptor.spec.ts · y el filtro de excepciones
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.

src/tasks/tasks.controller.spec.ts · capa HTTP sin aplicación completa
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ónFidelidadVelocidadComplejidad en CICuá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ámicoAltísima: arranque en milisegundos, sin redNula: es una dependencia npmSolo si tu acceso a datos es CRUD trivial. Prototipos y bibliotecas
PostgreSQL con TestcontainersMáxima: el mismo motor y versión que producciónMedia: 2–10 s de arranque del contenedor, reutilizable entre suitesMedia: 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ónAlta: ya está arrancadaBaja en CI (services:), pero exige que cada desarrollador la tenga y esté al díaSuites grandes donde el arranque del contenedor por proceso duele
El coste real de SQLite: falsos negativos y falsos positivos Es la elección que más tiempo parece ahorrar y más caro sale. Falso negativo: tu test pasa con 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.
test/integration/db.ts · Testcontainers una vez por proceso
// 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

EstrategiaCómoVentajas e inconvenientes
Transacción con rollbackAbrir transacción en beforeEach, deshacerla en afterEachRapidí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 tablasTRUNCATE ... RESTART IDENTITY CASCADE en beforeEachFunciona siempre, incluso en e2e. Cuesta unos milisegundos y hay que respetar el orden o usar CASCADE
refreshDatabase() por testRecrear el esquema completoMáxima garantía y lentitud (cientos de milisegundos): solo para tests de esquema o migraciones
Datos disjuntosCada test usa sus propios ids o su propio tenantSin limpieza y muy rápido, pero un test que hace find() sin filtro ve datos ajenos
aislamiento y caché de identidadINCORRECTO
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
});
aislamiento y caché de identidadCORRECTO
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`);
}
La Identity Map es la trampa número uno de los tests con MikroORM El 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.
test/integration/tasks.repository.spec.ts · relaciones y SQL generado
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.

El arranque es el coste, y se paga por proceso Arrancar PostgreSQL en un contenedor cuesta entre dos y diez segundos. Con veinte archivos de test de integración y un contenedor por archivo estarías pagando entre cuarenta segundos y tres minutos solo en arranques, y además saturando Docker con veinte instancias simultáneas. Como Jest ejecuta cada archivo en un proceso distinto, la variable de módulo que guarda «el contenedor ya arrancado» no se comparte entre archivos: cada uno la ve vacía y arranca el suyo. Este es el error de diseño más caro de esta técnica.

Las tres formas de compartir el contenedor

EstrategiaCómo funcionaCoste típicoCuándo
Uno por archivo
beforeAll en cada .spec.ts
Cada proceso arranca y destruye el suyo2–10 s × número de archivosUn proyecto con dos o tres archivos de integración; o un test que necesita una versión distinta del motor
Uno por ejecución
globalSetup / globalTeardown
Jest ejecuta ese archivo una sola vez, antes de crear los workers; la URL se pasa por process.env2–10 s en totalLa 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ónBucle de desarrollo local, donde se lanza la suite decenas de veces al día. Nunca en CI
test/setup/global-setup.ts · un contenedor para toda la suite, reutilizable en local
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());
}
test/setup/global-teardown.ts
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 datos
Docker en CI y el error Could 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.

EstrategiaCoste por testAislamientoLimitación decisiva
Transacción con reversión~1 ms. ROLLBACK descarta el registro de deshacer; no toca las tablasTotal dentro del procesoSolo 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 tablas5–30 ms según número de tablas: es DDL, exige bloqueo exclusivo y reescribe el ficheroTotal, también entre conexionesRequiere CASCADE o el orden topológico correcto; y borra los datos de referencia, que hay que resembrar
DELETE FROM por tablaMás lento que TRUNCATE con muchas filas, más rápido con muy pocas y sin bloqueo exclusivoTotalNo reinicia las secuencias; deja las tablas infladas si no hay autovacuum
Base de datos por test desde plantilla50–200 ms. CREATE DATABASE ... TEMPLATE copia ficheros a nivel de sistemaAbsoluto: procesos distintos, catálogos distintosCuesta 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 modeloAbsolutoInviable por test; correcto una vez por archivo o por worker
test/integration/tx-isolation.ts · reversión automática por test
// 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ó
  });
});
Dónde se rompe la reversión, y por qué el fallo es tan desconcertante Si el servicio bajo test llama a 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.
test/setup/template-db.ts · base de datos plantilla, para migraciones y aislamiento absoluto
// 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ónPostgreSQLSQLiteConsecuencia práctica en tu suite
Sistema de tiposEstricto y estático: uuid, jsonb, numeric, timestamptz, text[], tipos enum propiosAfinidad dinámica: cinco clases de almacenamiento; un varchar(10) acepta un texto de 5000 caracteresEl test acepta datos que producción rechaza. Un enum con un valor inválido pasa en verde y falla en el despliegue
DialectoILIKE, DISTINCT ON, funciones de ventana completas, RETURNING avanzado, operadores ->> y @> de JSON, array_agg, tsvectorNada de ILIKE (LIKE ya ignora mayúsculas en ASCII), sin tipos array nativos, JSON limitado, sin tsvectorToda 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
ConcurrenciaMVCC: lectores y escritores no se bloquean; SELECT ... FOR UPDATE, niveles de aislamiento reales, detección de interbloqueosBloqueo a nivel de toda la base de datos; un único escritor; SQLITE_BUSY en lugar de esperarEs 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
RestriccionesClaves ajenas siempre activas; DEFERRABLE; restricciones CHECK y de exclusiónClaves 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 zonastimestamptz normaliza a UTC y conserva el instante; aritmética con intervalGuarda texto o número; sin tipo fecha; sin noción de zonaLos bugs de zona horaria —los más caros de diagnosticar— son invisibles en el test
Ordenación y colaciónDepende de la colación de la base de datos; NULL primero en ASCComparación binaria; NULL al final en ASCUn test de paginación ordenada pasa con un orden y producción devuelve otro: paginación con elementos duplicados o perdidos
ErroresSQLSTATE normalizado: 23505 unicidad, 23503 clave ajena, 40001 serializaciónCódigos propios y mensajes distintosEl catch que traduce «violación de unicidad» a 409 nunca se ejercita de verdad
Analogía: ensayar en un escenario con otras medidas Una compañía que ensaya una obra en una sala de 8 metros y la estrena en una de 20 no ha ensayado la obra: ha ensayado otra obra que se le parece. Las entradas y salidas no cuadran, las distancias son otras y la proyección de voz no llega. Todo lo aprendido sobre el texto sigue valiendo; todo lo aprendido sobre el espacio, no. SQLite te deja ensayar el texto —tus reglas de negocio— pero no el espacio, que es donde ocurren los accidentes: la concurrencia, los tipos, los bloqueos y las restricciones.

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.

atomicidadINCORRECTO
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);
});
atomicidadCORRECTO
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écnicaQué simulaCómo
Doble de un colaboradorUn servicio externo que falla: pasarela, cola, otro microserviciomockRejectedValueOnce() en el colaborador que se invoca al final
Violación de restricción realUn choque de unicidad o de clave ajena que solo la base de datos conocePreparar el dato conflictivo antes y dejar que el flush reviente de verdad
Spy parcial sobre el EntityManagerUn fallo de red o del driver en la enésima escriturajest.spyOn(em, 'flush') que delega en el real y falla en la segunda llamada
Sentencia que abortaUn timeout de sentencia o una cancelación por parte del servidorSET LOCAL statement_timeout = '50ms' más una consulta lenta, o pg_terminate_backend
test/integration/transfers.int-spec.ts · fallo a mitad, con restricción real
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.

test/integration/optimistic-lock.int-spec.ts
// 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();
    });
  });
});
Interbloqueos: por qué conviene tener un test que los provoque Un interbloqueo ocurre cuando dos transacciones toman los mismos bloqueos en orden distinto. PostgreSQL lo detecta al cabo de un segundo y mata a una de las dos con el código 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.

test/tasks/report.queue.spec.ts · los tres niveles de un trabajo en cola
// 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();
  });
});
test/integration/report.queue.int-spec.ts · extremo a extremo con Redis real
// 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.

subscriptions.service.tsINCORRECTO
@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());
});
subscriptions.service.tsCORRECTO
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);
});
La familia completa de dependencias ocultas El reloj es la más famosa, pero no está sola. 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.

test/retry.spec.ts · reintentos con espera exponencial, sin esperar
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');
  });
});
Temporizadores falsos y base de datos real no se llevan bien Si congelas el tiempo mientras hay una conexión abierta a PostgreSQL o a Redis, sus timeouts internos, sus keep-alive y sus reintentos dejan de dispararse: la consulta se queda esperando un temporizador que solo avanzará si tú lo avanzas, y el test se cuelga hasta agotar el timeout de Jest. Reglas: usa temporizadores falsos solo en tests unitarios; en integración y e2e, inyecta un 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.

src/reports/nightly.service.spec.ts
@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);
  });
});
Tareas programadas y varias instancias Un detalle que no es de testing pero que se descubre testeando: si despliegas tres réplicas del servicio, el @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.

test/setup-unit.ts · determinismo por defecto para toda la suite
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.
El azar controlado también sirve para encontrar bugs: property-based testing Existe una técnica que usa la aleatoriedad a tu favor en lugar de sufrirla. En vez de escribir tres ejemplos, declaras una propiedad que debe cumplirse siempre —«normalizar un slug dos veces da lo mismo que normalizarlo una», «la suma de las páginas es igual al total»— y una biblioteca como 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?».

test/utils/create-test-app.ts · un único sitio para la configuración global
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()) };
}
configuración global duplicadaINCORRECTO
// 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.
configuración global compartidaCORRECTO
// 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ónA favorEn contraCuándo
Token real por POST /auth/loginPrueba de verdad el registro, el hash, el login y el guard; el token es idéntico al de producciónAñade dos peticiones por suite y acopla todos los tests al flujo de loginPor defecto, encapsulado en un helper loginAs() ejecutado una vez
Token firmado con el JwtService de la appInstantáneo; permite forjar cualquier rol, un token expirado o con claims rarosNo ejercita el login; si cambia el contenido del payload, el test puede quedar obsoletoPara probar autorización, expiración y casos límite del token
Sustituir el guard con overrideGuardTrivial y rapidísimoDeja de probar la seguridad: es exactamente la parte que no puedes permitirte no probarSolo en tests centrados en otra cosa, y nunca en la suite de autorización
test/e2e/flujo-completo.e2e-spec.ts · registro → login → proyecto → tarea → filtro → borrado → 404
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);
  });
});
Organización: un archivo por recurso y ningún acoplamiento entre archivos Dentro de un archivo puedes narrar un escenario en orden, como arriba. Entre archivos, jamás: Jest los ejecuta en paralelo y en un orden que depende de la duración previa de cada uno. Reglas prácticas: cada archivo crea sus datos y no depende de seeds globales mutables; nada de fechas relativas al «hoy» real (usa fechas fijas o un reloj inyectado); nada de 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 testProblema de diseñoPrincipio SOLID
Muchos mocks para un solo casoLa clase tiene demasiadas responsabilidades y colaboradoresS: responsabilidad única
Hay que mockear métodos que el caso no usaDependes de interfaces demasiado anchasI: segregación de interfaces
Imposible sustituir una dependencia (new o import directo)Acoplamiento a una implementación concretaD: inversión de dependencias
Hay que mockear Date, randomUUID, fetchEfectos no explícitos escondidos en el dominioD: inyecta Clock, IdGenerator, HttpClient
Añadir un caso obliga a tocar el test de los demásCondicionales en cascada donde debería haber polimorfismoO: abierto/cerrado
Un doble de la subclase rompe tests de la baseLa subclase no cumple el contrato del padreL: 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

fixtures gigantes compartidosINCORRECTO
// 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);
});
factorías locales al testCORRECTO
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);
});
test/factories/task.factory.ts · builder con valores por defecto y sobrescritura
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 sin semilla es flakiness garantizada Un día 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.

Analogía: el contrato de alquiler frente a la convivencia Para saber si un piso cumple lo pactado no hace falta que propietario e inquilino vivan juntos una semana: basta con leer el contrato y comprobar que cada parte cumple su mitad. El contract testing es exactamente eso. El consumidor declara «necesito que 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 contratoCada consumidor, escribiendo un test contra un servidor simuladoEl proveedor, generando el esquema desde sus decoradores
Qué se verificaQue el proveedor satisface lo que cada consumidor usa realmenteQue la API no ha cambiado de forma incompatible y que las respuestas se ajustan al esquema
Ventaja decisivaDetecta que puedes borrar un campo que nadie usa, y que no puedes borrar el que sí usa alguienCoste casi nulo: el esquema ya existe si documentas con Swagger. Genera clientes tipados gratis
CosteAlto: exige un broker, disciplina en ambos lados y coordinación organizativaBajo: dos ficheros de CI. No sabe qué usa cada consumidor
CuándoVarios equipos y servicios independientes, despliegues descoordinadosEl 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

test/contract/openapi.snapshot.ts · el esquema como artefacto versionado
// 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();
});
.github/workflows/contract.yml · detectar cambios incompatibles antes de mergear
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 --noEmit

Ese ú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.

Qué es un cambio incompatible y qué no lo es Rompen a los consumidores: eliminar o renombrar un campo de una respuesta, hacer obligatorio un campo de entrada que era opcional, estrechar un tipo (de 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

PruebaPregunta que respondePatrón de cargaQué revela
De carga
(load)
¿Cumplimos el SLO con el tráfico esperado?Subida gradual hasta la carga nominal y meseta de 10–30 minLatencia 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ónEl 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 hFugas 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ásSi 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.

load/tasks.load.js · carga, picos y umbrales sobre TaskFlow
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 cosa
La alternativa mínima: autocannon 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.

Cinco formas de obtener números que no significan nada 1) Medir contra una base de datos con cien filas: sin datos realistas todos los planes de ejecución son un escaneo secuencial barato y ningún índice se ejercita. 2) No calentar: las primeras peticiones pagan la compilación JIT, la apertura del pool y las cachés frías; descarta el primer minuto. 3) Ejecutar el generador de carga en la misma máquina que la aplicación: compiten por la CPU y mides la contienda. 4) Ignorar la omisión coordinada: si tu cliente espera la respuesta antes de enviar la siguiente petición, cuando el servidor se atasca el cliente deja de enviar y la latencia medida sale artificialmente buena —por eso los picos se modelan con 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.

tasks.service.tsINCORRECTO
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;
  }
}
tasks.service.tsCORRECTO
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.

src/logging/logging.module.ts · configuración de producción
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));
Redacta por lista blanca, no por lista negra 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.

src/logging/request-context.ts
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.

El equilibrio entre ruido y silencio, y el muestreo Demasiado log cuesta dinero (los sistemas de logs facturan por volumen), esconde lo importante y llega a frenar el proceso, porque escribir en stdout es E/S síncrona. Demasiado poco te deja a ciegas. Criterios: si nunca vas a buscar esa línea, no la escribas; si es un bucle de miles de iteraciones, registra el resumen y no cada paso; para endpoints de altísimo volumen, aplica muestreo (una de cada cien peticiones correctas) pero nunca muestrees los errores. Un log que solo aparece cuando hay un problema vale cien que aparecen siempre.

13.16 Métricas y trazas

PilarQué esA qué pregunta respondeCoste
LogsEventos discretos con contexto¿Qué pasó exactamente en esta petición concreta?Alto y creciente con el tráfico
MétricasSeries 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
TrazasEl 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).

src/metrics/metrics.module.ts
@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 });
    }));
  }
}
Percentiles, no medias La media miente: con 99 peticiones de 10 ms y una de 5 s, la media es 60 ms y parece excelente, aunque un usuario esperó cinco segundos. Mide p50 (la experiencia típica), p95 y p99 (la cola, que es donde viven los clientes enfadados). Con un histograma se calculan en el servidor: 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.
src/tracing.ts · se importa PRIMERO en main.ts
// 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.
src/logging/logging.module.ts · estampar la traza en cada línea de log
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();
  }
}
Las fronteras donde se pierde el hilo La correlación es tan fuerte como su eslabón más débil, y hay cuatro puntos donde se rompe casi siempre. 1) Las colas: el trabajo se procesa en otro proceso, minutos después; hay que serializar el contexto (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ñalQué mideCómo se instrumentaMatiz que casi todo el mundo pasa por alto
LatenciaCuánto tarda una peticiónHistograma por ruta y métodoHay 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áficoDemanda sobre el sistemaContador de peticiones por segundoUna caída brusca de tráfico es tan alarmante como una subida: casi siempre significa que algo aguas arriba ya no llega hasta ti
ErroresFracción de peticiones fallidasContador con etiqueta de estadoLos 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ónCuán lleno está el recurso más limitadoGauge: pool de conexiones, memoria, cola de trabajos, retraso del bucle de eventosEs 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.

src/metrics/business.metrics.ts
// 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.
Cardinalidad: el error que tumba el sistema de métricas Cada combinación distinta de valores de etiqueta crea una serie temporal independiente, con su propia memoria y su propio índice. Una etiqueta con el identificador de usuario en un servicio con cien mil usuarios crea cien mil series por métrica; añade el 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 disponibilidadPresupuesto al mesPresupuesto al añoQué implica en la práctica
99% («dos nueves»)7 h 18 min3,65 díasSuficiente para una herramienta interna. Permite mantenimientos con parada
99,9% («tres nueves»)43 min 50 s8,76 hObjetivo razonable para un SaaS como TaskFlow. Exige despliegues sin parada y reintentos
99,95%21 min 54 s4,38 hUn solo incidente mal gestionado consume el mes entero. Exige guardias reales
99,99% («cuatro nueves»)4 min 23 s52,6 minNinguna 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 alertaPor quéNo merece una alertaPor qué
Consumo del presupuesto de error a 14,4× durante 5 minSíntoma que el usuario ya está sufriendo; queda margen para actuarCPU 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 seguidosAfecta a ingresos y no se recupera soloUn pod reiniciadoEl sistema se recuperó: es el mecanismo funcionando. Solo alerta el reinicio repetido
Cero altas o cero pagos en horario comercialFallo silencioso invisible a las métricas técnicasUn error 500 aisladoCon mil peticiones por minuto, uno es ruido. Alerta la tasa, no el evento
Cola de trabajos creciendo sin parar 15 minSaturación: predice la caída antes de que ocurraLatencia alta en /metrics o /healthNo los usa ningún cliente
Certificado que caduca en 14 díasAccionable, con margen y con final conocidoCualquier 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
La prueba del algodón de una alerta Antes de crear una alerta, respóndete a tres preguntas. ¿Hay alguien peor por esto? Si ningún usuario lo nota, no es una alerta, es una métrica de panel. ¿Hay algo que hacer ahora mismo? Si la respuesta es «esperar a ver», es un ticket, no un aviso urgente. ¿Existe un procedimiento escrito? Toda alerta que despierta a alguien debe enlazar a un runbook con los tres primeros pasos, porque a las cuatro de la mañana nadie razona bien. Y una regla higiénica que salva equipos: cada alerta que se dispara y resulta no ser accionable debe corregirse o borrarse esa misma semana. Una alerta que se ignora por costumbre es peor que ninguna, porque da la ilusión de estar vigilando.

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»).

src/health/health.controller.ts
@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 }),
    ]);
  }
}
alertas basadas en causasINCORRECTO
- 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.
alertas basadas en síntomas (SLO)CORRECTO
# 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 nadie

13.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

Depuración: comandos y configuración
# 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.js

En 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íntomaHerramientaCoste sobre el servicioQué te dice
«Falla para algunos usuarios y no sé por qué»Subir el nivel de log en caliente, acotado y temporalVolumen de logs y algo de E/S; nulo si se acotaEl 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 cola2–5% de latenciaEl reparto del tiempo entre capas y la consulta culpable
«La memoria sube y nunca baja»Instantáneas del montículo comparadasAlto: pausa el proceso durante la capturaQué 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 capturaLa función concreta que consume los ciclos
«Todo va lento pero la CPU está baja»Medición del retraso del bucle de eventosPrácticamente nuloSi 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 NodeNulo hasta que se disparaPilas, versiones, límites de recursos y estado del heap en el momento del fallo
src/admin/diagnostics.controller.ts · nivel de log dinámico, con caducidad
@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`) }; }
}
Las instantáneas del montículo no son gratis 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é.
src/observability/event-loop.ts · el indicador más infravalorado de Node
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.

Informes de diagnóstico: la caja negra de Node Arrancar con --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.
Metodología de diagnóstico en producción Con el sistema caído, la tentación es cambiar cosas a ver si mejora. El orden que funciona: 1) mitigar primero (revertir el despliegue, escalar, activar el circuit breaker) y diagnosticar después: el usuario no necesita tu explicación, necesita el servicio; 2) establecer los hechos: ¿desde cuándo, qué porcentaje de peticiones, qué rutas, qué instancias? Las métricas dan el cuándo, y ese cuándo casi siempre coincide con un despliegue, un cambio de configuración o un pico de tráfico; 3) formular una hipótesis falsable y buscar el dato que la refute, no el que la confirme; 4) bajar el nivel de detalle solo cuando haga falta: métricas → trazas de peticiones lentas → logs de una traza concreta → depurador o perfilador; 5) escribir un post mortem sin culpables y, sobre todo, el test que habría detectado el fallo.

13.18 CI: ejecutar la suite en GitHub Actions

.github/workflows/ci.yml
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

ErrorSíntomaCausaSolución
Handles abiertosJest 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 paraleloFalla al ejecutar toda la suite y pasa en solitarioCuatro workers escribiendo y truncando la misma base de datosUn esquema o base de datos por JEST_WORKER_ID; --runInBand como parche temporal
E2E sin configuración globalEl e2e pasa y producción devuelve 400 o 500createNestApplication() no aplica lo que hace main.tsExtraer configureApp(app) y llamarla desde ambos sitios (13.10)
Mocks que ocultan erroresCobertura del 95% y fallos en el primer despliegueEl mock devuelve lo que tú supones, no lo que hace la base de datosTests de integración reales para todo el acceso a datos; el mock, para la lógica
Identity MapEl test verifica un cambio que nunca se persistiófindOne devuelve el objeto cacheado, no una filaem.clear() o em.fork() entre escribir y leer
transactional mockeado en vacíoEl test pasa sin ejecutar la lógicajest.fn() devuelve undefined y el callback no correjest.fn(async (cb) => cb(em))
Suites lentasCada archivo tarda 10 s en arrancarSe importa AppModule entero para probar una función puraMódulos de test mínimos; AppModule solo en los e2e; SWC en lugar de ts-jest
Flakiness por fechasFalla el día 1, en fin de mes o al cambiar la horanew Date() y zonas horarias del runnerTZ=UTC, reloj inyectado o jest.useFakeTimers({ now }), y faker.seed()
Un contenedor por archivoLa suite de integración tarda minutos y Docker se saturaEl beforeAll se ejecuta una vez por proceso, y Jest crea uno por archivoArrancar en globalSetup y pasar la URL por process.env; .withReuse() en local (13.7.4)
Reversión inútil en e2eEl test crea un usuario y la petición HTTP devuelve 401La petición usa otro em.fork() y otra conexión: no ve la transacción abiertaTruncado en lugar de reversión para todo lo que pase por HTTP (13.7.5)
Temporizadores falsos con E/S realEl test se cuelga hasta agotar el timeout de JestuseFakeTimers() congela también los temporizadores internos del driver y las microtareasdoNotFake: ['nextTick', 'setImmediate']; y falsear el tiempo solo en unitarios, con Clock en integración
advanceTimersByTime sin awaitEl test se queda esperando una promesa que nunca resuelveLa versión síncrona avanza el reloj pero no vacía la cola de microtareasawait jest.advanceTimersByTimeAsync(ms) o runOnlyPendingTimersAsync()
Actualización perdidaDos operaciones concurrentes y solo se guarda unaSin 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 idempotenteCorreos, cargos o informes duplicados en producciónLa entrega de una cola es «al menos una vez»: un reintento reprocesa el trabajojobId estable, clave de idempotencia en base de datos y un test que procese el mismo trabajo dos veces
Explosión de cardinalidadPrometheus consume toda la memoria y muereEtiquetas con identificadores o URLs completas: una serie temporal por valorEtiquetar con la ruta parametrizada y categorías acotadas; la identidad va a logs y trazas (13.16.4)
Traza cortada en la colaLa traza termina en «trabajo encolado» y el consumidor aparece huérfanoEl contexto de OpenTelemetry no se serializa solo al cruzar el procesopropagation.inject en los metadatos del trabajo y extract en el consumidor (13.16.3)
Prueba de carga engañosaNúmeros excelentes en el ensayo y colapso el día del lanzamientoBase de datos vacía, cliente en la misma máquina, sin calentamiento u omisión coordinadaVolumen realista, generador de carga separado, descartar el primer minuto y modelar picos con arrival-rate (13.14.3)
Esquema OpenAPI desactualizadoEl frontend compila y falla en ejecuciónEl esquema se generó a mano o hace tres versionesGenerarlo en CI desde los decoradores, versionarlo y comparar con oasdiff en cada PR (13.13.2)
debug olvidado en producciónLa factura de logs se multiplica y no se encuentra nadaAlguien 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.ts y 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 requestId propagado.
  • 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_id en 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 expect por 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?
  • --forceExit para 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.log en 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 setTimeout arbitrario 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

  1. Fábrica de mocks. Implementa createMockEntityManager() de 13.4.2 en tu proyecto y escribe un test que demuestre que transactional ejecuta el callback y que persist es encadenable.
  2. 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.
  3. Guard aislado. Testea un ApiKeyGuard que lee x-api-key: clave válida, ausente, inválida y con espacios. Usa mockExecutionContext y no arranques la aplicación.
  4. Reloj inyectado. Sustituye los new Date() de un servicio de suscripciones por un Clock inyectado y escribe con it.each los 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

  1. Integración con SQL real. Monta createTestOrm() con Testcontainers y escribe un test del método search() de un repositorio con filtro combinado, paginación y ordenación con NULLS LAST. Añade una aserción que falle si aparece un N+1.
  2. E2E honesto. Extrae configureApp() de tu main.ts, úsala en createTestApp() y escribe el flujo completo de 13.10. Comprueba que un usuario no puede tocar recursos de otro (403) y que la respuesta no filtra passwordHash.
  3. Aislamiento. Implementa truncateAll() y demuestra con dos tests que el orden de ejecución no importa: ejecútalos con --shard=1/2 y --shard=2/2.
  4. Logging estructurado. Configura nestjs-pino con redacción y x-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.
  5. Un contenedor para toda la suite. Parte de una suite con Testcontainers en el beforeAll de cada archivo, mide su duración total, muévelo a globalSetup con un esquema por JEST_WORKER_ID y vuelve a medir. Documenta las dos cifras y explica de dónde sale la diferencia.
  6. 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.
  7. 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

  1. 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/live y /health/ready. Verifica en un e2e que /metrics expone http_requests_total con la etiqueta route normalizada.
  2. 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.
  3. Fuga de memoria. Introduce a propósito un Map global que crezca por petición, reprodúcela con un bucle de mil peticiones, captura dos heap snapshots y localiza la fuga comparándolos.
  4. CI completa. Monta el workflow de 13.18 con PostgreSQL como servicio, cuatro shards, migration:check y protección de rama. Añade un job que falle si la cobertura del dominio baja del 90%.
  5. 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 el OptimisticLockError a un 409 verificado por un e2e.
  6. 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.
  7. 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.
  8. Correlación de los tres pilares. Estampa el trace_id en 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

13.24 Recursos adicionales

Siguiente paso Ya sabes verificar el comportamiento antes de desplegar y explicarlo después. Lo que queda por dominar es la capa donde viven los errores más caros de todos, los de datos: el capítulo 14 empieza la parte de MikroORM con las entidades, el EntityManager y la unidad de trabajo, y ahí podrás aplicar directamente los tests de integración de 13.7 para verificar cada consulta contra una base de datos real.