Testing en NestJS

Por: Artiko
nestjsbuenas-practicastestingtestcontainerssupertestvitestjest

Testing en NestJS

Un backend sin tests no es “código sin tests”: es código cuyo comportamiento nadie puede afirmar sin desplegarlo. Y un backend con los tests equivocados es peor, porque da una señal verde falsa: la suite pasa, refactorizás con confianza y rompés producción igual. Este capítulo no trata de “escribir más tests”, sino de escribir los que realmente prueban algo y de ubicarlos en el nivel correcto de la pirámide.

NestJS tiene una ventaja estructural para testear: todo lo importante entra por inyección de dependencias. Si en los capítulos 3 (SOLID) y 8 (persistencia) diseñaste contra puertos e interfaces en vez de clases concretas, testear es casi gratis: sustituís la implementación real por un doble sin tocar el código de producción. Si acoplaste la lógica de negocio al PrismaClient o al HttpService, cada test va a pelear contra ese acoplamiento. El testing es, en gran parte, la factura del diseño.

Jest hoy, Vitest mañana

NestJS 11 viene con Jest preconfigurado (jest + ts-jest, más supertest para e2e). Todo lo de este capítulo funciona tal cual con Jest.

La dirección de NestJS 12 es migrar a Vitest por defecto en los proyectos ESM (los proyectos CommonJS seguirán con Jest). Vitest es nativo de ESM y usa OXC para soportar decoradores de TypeScript, con lo que desaparece la cadena jest + ts-jest + configuración de ESM. La buena noticia: la API de Vitest es compatible con la de Jest (describe, it, expect, vi.fn() en lugar de jest.fn()), y los helpers de @nestjs/testing (Test.createTestingModule, overrideProvider, etc.) son agnósticos del runner. Nada de la arquitectura de tests que ves acá cambia; solo el motor que la ejecuta y, con Vitest, un cold start entre 2x y 10x más rápido.

En los ejemplos uso la API de Jest por ser la de Nest 11; donde diga jest.fn(), con Vitest sería vi.fn().

La pirámide de tests en el backend

La pirámide describe cuántos tests de cada tipo te conviene tener, según su costo y su fidelidad. Muchos rápidos y baratos en la base; pocos lentos y fieles en la cima.

flowchart TB
    E2E["<b>e2e</b> — pocos, lentos, alta fidelidad<br/>App real + HTTP + guards/pipes/filters + BD real"]
    INT["<b>Integración</b> — cantidad media<br/>Varios providers reales juntos, sin HTTP"]
    UNIT["<b>Unitarios</b> — muchos, rápidos, aislados<br/>Un service, dependencias como dobles, sin IO"]
    UNIT --> INT --> E2E

    classDef base fill:#1d4ed8,stroke:#1e3a8a,color:#fff
    classDef mid fill:#0891b2,stroke:#155e75,color:#fff
    classDef top fill:#be123c,stroke:#881337,color:#fff
    class UNIT base
    class INT mid
    class E2E top

El antipatrón clásico es la pirámide invertida (o el “reloj de arena”): montañas de tests e2e lentos y frágiles, casi ningún unitario. La suite tarda 20 minutos, falla por timing y nadie confía en ella. Regla práctica: si una lógica se puede probar en un unitario, probala ahí; reservá e2e para los flujos, no para cada rama de un if.

Tests unitarios: aprovechar la DI

El módulo @nestjs/testing te da Test.createTestingModule(), una versión de test del contenedor IoC. Construís un módulo con solo los providers que necesitás y sobrescribís los colaboradores con dobles.

Partamos de un service diseñado contra un puerto (un token de interfaz, como vimos en el capítulo 8). No depende de Prisma ni de HTTP: depende de la abstracción UserRepository.

// user-repository.port.ts
export const USER_REPOSITORY = Symbol('USER_REPOSITORY');

export interface UserRepository {
  findByEmail(email: string): Promise<User | null>;
  save(user: User): Promise<void>;
}
// users.service.ts
@Injectable()
export class UsersService {
  constructor(
    @Inject(USER_REPOSITORY) private readonly users: UserRepository,
    @Inject(CLOCK) private readonly clock: Clock,
  ) {}

  async register(dto: RegisterDto): Promise<User> {
    const existing = await this.users.findByEmail(dto.email);
    if (existing) {
      throw new ConflictException('El email ya está registrado');
    }
    const user = User.create(dto, this.clock.now());
    await this.users.save(user);
    return user;
  }
}

El test unitario no toca la base de datos. Inyecta un doble del repositorio y verifica la lógica de negocio: que rechaza emails duplicados, que persiste el usuario nuevo, que sella la fecha con el reloj inyectado.

// users.service.spec.ts
describe('UsersService', () => {
  let service: UsersService;
  let repo: jest.Mocked<UserRepository>;

  beforeEach(async () => {
    repo = { findByEmail: jest.fn(), save: jest.fn() };

    const moduleRef = await Test.createTestingModule({
      providers: [
        UsersService,
        { provide: USER_REPOSITORY, useValue: repo },
        { provide: CLOCK, useValue: { now: () => new Date('2026-07-17') } },
      ],
    }).compile();

    service = moduleRef.get(UsersService);
  });

  it('rechaza un email ya registrado', async () => {
    repo.findByEmail.mockResolvedValue({ id: '1' } as User);

    await expect(service.register({ email: '[email protected]' } as RegisterDto))
      .rejects.toBeInstanceOf(ConflictException);
    expect(repo.save).not.toHaveBeenCalled();
  });

  it('persiste el usuario nuevo con la fecha del reloj', async () => {
    repo.findByEmail.mockResolvedValue(null);

    const user = await service.register({ email: '[email protected]' } as RegisterDto);

    expect(repo.save).toHaveBeenCalledWith(user);
    expect(user.createdAt).toEqual(new Date('2026-07-17'));
  });
});

Dos detalles clave:

const moduleRef = await Test.createTestingModule({ imports: [UsersModule] })
  .overrideProvider(USER_REPOSITORY).useValue(repo)
  .overrideProvider(CLOCK).useClass(FakeClock)   // o .useFactory({ factory: () => ... })
  .compile();

Por qué el diseño con puertos hace esto trivial

Compará con el antipatrón: un service que depende directo de la clase concreta de infraestructura.

// ANTIPATRÓN: acoplado a la implementación concreta
@Injectable()
export class UsersService {
  constructor(private readonly prisma: PrismaService) {}

  async register(dto: RegisterDto) {
    const existing = await this.prisma.user.findUnique({ where: { email: dto.email } });
    // ...
  }
}

Para testear esto tenés que mockear la cadena prisma.user.findUnique, un objeto anidado con decenas de métodos que no controlás. El mock termina reflejando la API de Prisma, no la intención de tu negocio; y cada cambio en cómo consultás (un findFirst en vez de un findUnique) rompe el test aunque la lógica sea idéntica. Ese es un test acoplado a la implementación.

Con el puerto UserRepository de dos métodos, el doble es un objeto con dos jest.fn(). El test habla el idioma del dominio (findByEmail, save), no el del ORM. Refactorizás la implementación del repositorio sin tocar ni un test del service. Esto es la Inversión de Dependencias del capítulo 3 pagando dividendos: testeás contra la abstracción que definiste, no contra la librería de un tercero.

Para dobles masivos, createTestingModule acepta .useMocker(): una función que auto-genera un doble para cada dependencia no provista. Útil para services con muchos colaboradores, pero usalo con criterio: si tenés que auto-mockear diez dependencias, el problema real es que el service viola SRP.

Tests de integración de módulos

Un test de integración arma varios providers reales juntos y verifica que colaboran, sin levantar HTTP. La diferencia con el unitario: acá no mockeás a los colaboradores internos; solo cortás en el borde del IO externo (la base, una API de terceros).

describe('Registro (integración)', () => {
  let service: UsersService;
  let events: jest.Mocked<EventBus>;

  beforeEach(async () => {
    events = { publish: jest.fn() };

    const moduleRef = await Test.createTestingModule({
      providers: [
        UsersService,
        UserFactory,          // provider real
        PasswordHasher,       // provider real
        InMemoryUserRepository,               // implementación real en memoria del puerto
        { provide: USER_REPOSITORY, useExisting: InMemoryUserRepository },
        { provide: EVENT_BUS, useValue: events }, // borde externo: doble
      ],
    }).compile();

    service = moduleRef.get(UsersService);
  });

  it('registra y hashea el password de verdad', async () => {
    const user = await service.register({ email: '[email protected]', password: 'secreto123' } as RegisterDto);
    expect(user.passwordHash).not.toBe('secreto123');
    expect(events.publish).toHaveBeenCalledWith(expect.objectContaining({ type: 'user.registered' }));
  });
});

Acá el PasswordHasher y la UserFactory son reales: si el hasheo cambia y rompe algo, este test lo agarra, cosa que un unitario con todo mockeado no haría. Solo el EventBus (que hablaría con una cola) queda como doble. La persistencia real la cubrimos con Testcontainers, más abajo.

Tests e2e con Supertest

Los e2e levantan la aplicación completa con createNestApplication() y le pegan con Supertest, que arma requests HTTP contra el servidor. Acá se prueban las capas transversales que un unitario nunca ve: guards, pipes, interceptores y exception filters (todo el ciclo de vida del capítulo 6).

// users.e2e-spec.ts
describe('Users (e2e)', () => {
  let app: INestApplication;

  beforeAll(async () => {
    const moduleFixture = await Test.createTestingModule({
      imports: [AppModule],
    }).compile();

    app = moduleFixture.createNestApplication();
    app.useGlobalPipes(new ValidationPipe({ whitelist: true, transform: true }));
    await app.init();
  });

  afterAll(async () => {
    await app.close();
  });

  it('POST /users rechaza un body inválido con 400 (ValidationPipe real)', () => {
    return request(app.getHttpServer())
      .post('/users')
      .send({ email: 'no-es-email' })
      .expect(400);
  });

  it('GET /users/:id sin token responde 401 (guard real)', () => {
    return request(app.getHttpServer())
      .get('/users/1')
      .expect(401);
  });

  it('POST /users crea el usuario y devuelve 201', async () => {
    const res = await request(app.getHttpServer())
      .post('/users')
      .send({ email: '[email protected]', password: 'secreto123' })
      .expect(201);

    expect(res.body).toMatchObject({ email: '[email protected]' });
    expect(res.body.passwordHash).toBeUndefined(); // el interceptor de serialización lo ocultó
  });
});

Puntos importantes:

Testcontainers: bases de datos reales y efímeras

Acá está la decisión que más distingue una suite confiable de una que miente. ¿Cómo testeás la capa de persistencia (tus queries, transacciones, constraints, migraciones)?

Con el módulo @testcontainers/postgresql (v12 a mediados de 2026), el patrón es directo:

// user-repository.integration-spec.ts
import { PostgreSqlContainer, StartedPostgreSqlContainer } from '@testcontainers/postgresql';

describe('PrismaUserRepository (Testcontainers)', () => {
  let container: StartedPostgreSqlContainer;
  let prisma: PrismaClient;
  let repo: PrismaUserRepository;

  beforeAll(async () => {
    container = await new PostgreSqlContainer('postgres:17-alpine').start();

    prisma = new PrismaClient({
      datasources: { db: { url: container.getConnectionUri() } },
    });
    // aplicar el esquema real contra el contenedor
    execSync('prisma migrate deploy', {
      env: { ...process.env, DATABASE_URL: container.getConnectionUri() },
    });

    repo = new PrismaUserRepository(prisma);
  }, 60_000); // el pull de la imagen la primera vez puede tardar

  afterAll(async () => {
    await prisma.$disconnect();
    await container.stop();
  });

  afterEach(async () => {
    await prisma.user.deleteMany(); // aislar estado entre tests
  });

  it('respeta el UNIQUE constraint de email a nivel BD', async () => {
    await repo.save(User.create({ email: '[email protected]' }));
    await expect(repo.save(User.create({ email: '[email protected]' })))
      .rejects.toThrow(); // el constraint real dispara, no un check de JS
  });

  it('findByEmail hace match case-insensitive con ILIKE', async () => {
    await repo.save(User.create({ email: '[email protected]' }));
    const found = await repo.findByEmail('[email protected]');
    expect(found).not.toBeNull(); // esto SQLite no lo probaría igual
  });
});

API relevante del módulo:

sequenceDiagram
    participant T as Suite de tests
    participant TC as Testcontainers
    participant D as Docker
    participant PG as Postgres (contenedor)
    participant R as Repositorio real

    T->>TC: new PostgreSqlContainer().start()
    TC->>D: crear + arrancar contenedor efímero
    D->>PG: healthcheck OK
    PG-->>TC: listo
    TC-->>T: getConnectionUri()
    T->>PG: migrate deploy (esquema real)
    loop cada test
        T->>R: save() / findByEmail()
        R->>PG: SQL real (constraints, ILIKE, jsonb)
        PG-->>R: resultado real
        R-->>T: assert
        T->>PG: deleteMany() (aislar estado)
    end
    T->>TC: container.stop()
    TC->>D: destruir contenedor

El costo es el arranque del contenedor (segundos, más el pull de la imagen la primera vez). Se amortiza levantándolo una vez por archivo (beforeAll) y aislando el estado entre tests con truncado/deleteMany o transacciones con rollback, no recreando el contenedor por test.

Estrategia de dobles: cuándo mockear y cuándo no

La regla se resume en una línea: mockeá lo que no controlás; no mockees tu propia lógica.

flowchart TD
    Q{¿Qué es la<br/>dependencia?}
    Q -->|Lógica de negocio propia| NO["NO mockear<br/>(usar la real: si la mockeás,<br/>no probás nada)"]
    Q -->|IO externo: API HTTP,<br/>cola, email, pagos| SI["Mockear<br/>(doble determinista,<br/>sin red en unit/integración)"]
    Q -->|Base de datos propia| TC["Testcontainers<br/>(real y efímera,<br/>no mock ni SQLite)"]
    Q -->|Tiempo, aleatorio, UUID| INY["Inyectar puerto<br/>(Clock, IdGenerator)<br/>y usar un fake"]

    classDef no fill:#166534,stroke:#14532d,color:#fff
    classDef si fill:#b45309,stroke:#78350f,color:#fff
    class NO,TC,INY no
    class SI si

Antipatrones de testing

Mockear todo (el test que no prueba nada). Cuando cada colaborador es un doble y las aserciones solo verifican que “se llamó a save()”, el test es un espejo del código: reescribe la implementación en el expect. Si borrás la lógica del service, el test sigue verde porque nunca ejecutó lógica real. Señal de alarma: un test donde no aparece ningún valor de negocio calculado, solo toHaveBeenCalledWith.

Tests acoplados a la implementación. Verificar que se llamó a prisma.user.findUnique en vez de verificar el resultado observable (se rechazó el duplicado). Cualquier refactor interno rompe el test aunque el comportamiento sea idéntico. Testeá qué hace el código (contrato/salida), no cómo lo hace (llamadas internas).

e2e sin aislar estado entre tests. El test A crea un usuario, el test B cuenta usuarios y espera 0. Pasan en un orden, fallan en otro, fallan al paralelizar. Cada test debe partir de un estado conocido: truncar tablas en afterEach, o envolver cada test en una transacción con rollback. Nunca asumas orden de ejecución.

Base de datos compartida entre desarrolladores o con CI. Una BD “de test” común genera flakiness (dos corridas se pisan) y falsos verdes (datos que quedaron de antes). Con Testcontainers cada corrida tiene su base efímera y aislada: se elimina la clase entera de bugs por estado compartido.

Suite lenta por no paralelizar. Tests que comparten una única BD no pueden correr en paralelo sin pisarse, así que la suite se serializa y tarda. Con contenedor/esquema por worker, Jest (--maxWorkers) o Vitest paralelizan y el tiempo total baja proporcional a los cores. La lentitud casi siempre es un síntoma de estado compartido, no del volumen de tests.

No testear caminos de error ni de autorización. El happy path es el que menos se rompe en producción. Lo que revienta es el 401 que no salta, el 403 que un usuario esquiva, el 409 de un duplicado, el 400 de un input inválido. Todo endpoint con guard necesita al menos un test que confirme que rechaza sin credenciales y que un rol insuficiente recibe 403. La cobertura que importa es la de los bordes, no la de la línea feliz.

Checklist

Con la suite en su lugar, el siguiente paso es consolidar todo el curso en decisiones repetibles: qué librerías y qué patrones adoptar, y qué antipatrones erradicar. Seguimos con Librerías y catálogo de patrones/antipatrones →.