Testing en NestJS
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
- Unitarios: aíslan una unidad de lógica (típicamente un service) y reemplazan sus colaboradores por dobles. Corren en milisegundos, sin red ni disco. Son la base porque son donde vive la mayor parte de tu lógica de negocio y donde el feedback es más rápido.
- Integración: ejercitan varios providers reales juntos (un service con su repositorio, un mapper, un validador), sin levantar el servidor HTTP. Verifican que las piezas encajan, no cada rama de negocio.
- e2e (end-to-end): levantan la aplicación completa y le pegan por HTTP. Prueban el request real pasando por guards, pipes, interceptores y exception filters, con la base de datos incluida. Son los más caros y los que más confianza dan sobre “¿funciona de verdad?”.
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:
.compile()devuelve elTestingModule. Para providers en scope por defecto (singleton) usásmoduleRef.get(Token). Si el provider esREQUEST/TRANSIENTscoped,get()no sirve: usásawait moduleRef.resolve(Token), que crea una instancia por contexto.useValueinyecta un objeto literal como doble. También podés usar.overrideProvider(TOKEN).useValue(...)cuando importás un módulo entero y solo querés reemplazar una pieza:
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,
createTestingModuleacepta.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:
request(app.getHttpServer()): Supertest arranca el servidor en un puerto efímero y le pega. No hardcodeeslocalhost:3000.- El e2e sí debe validar caminos de error y de autorización: el 400 del
ValidationPipe, el 401/403 de los guards. Un e2e que solo prueba el happy path deja sin cubrir justo donde más se rompe la seguridad. - Si querés e2e sin la BD real pero probando guards/pipes, podés sobrescribir en el fixture:
.overrideGuard(JwtAuthGuard).useValue({ canActivate: () => true }), ooverridePipe,overrideInterceptor,overrideFilter. Útil para aislar la capa HTTP; pero para probar persistencia real, mejor Testcontainers.
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)?
- Mockear el repositorio: no prueba nada de la persistencia. El mock dice lo que vos le programaste; si tu query real tiene un
WHEREmal, el mock igual devuelve verde. Sirve para tests de service, no para tests de repositorio. - SQLite en memoria: rápido, pero miente. No tiene los mismos tipos, ni el mismo comportamiento de constraints, ni funciones de Postgres (
jsonb,ON CONFLICT, arrays,ILIKE, tiposenum). Un test verde contra SQLite puede reventar contra el Postgres de producción. Estás testeando una base que no usás. - Testcontainers: levanta un Postgres real en Docker, efímero, por corrida de tests. Misma versión, mismos tipos, mismos constraints que producción. Al terminar, se destruye. Es la fidelidad de una base real sin el estado compartido de una base “de test” permanente.
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:
new PostgreSqlContainer('postgres:17-alpine')define el contenedor;.start()lo levanta y devuelve unStartedPostgreSqlContainer..getConnectionUri()te da la cadenapostgres://user:pass@host:puerto/dblista para el cliente; también tenésgetHost(),getPort(),getDatabase(),getUsername(),getPassword()por separado..stop()destruye el contenedor enafterAll.- Para servicios sin módulo dedicado (Redis, RabbitMQ, tu propia imagen), usás el genérico
new GenericContainer('imagen').withExposedPorts(6379).start()y luego.getMappedPort(6379)+.getHost().
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
- Mockeá: llamadas HTTP a terceros (Stripe, un proveedor de email), colas, servicios externos que no querés golpear ni volver lentos y no deterministas tus tests. Y abstraé lo no determinista (
Date.now(),Math.random(),randomUUID()) detrás de un puerto inyectable (Clock,IdGenerator) para poder fijarlo en el test. - No mockees: tu propia lógica de dominio, tus mappers, tus validadores, tu factory. Si el test reemplaza tu lógica por un doble, no queda nada real bajo prueba. Y no mockees la base para tests de persistencia: ahí va Testcontainers.
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
- La lógica de negocio vive en services que dependen de puertos/tokens, no de clases concretas de infraestructura (Prisma, HttpService).
- Los tests unitarios usan
Test.createTestingModule+overrideProvider/useValuey no tocan red ni disco. - Usás
moduleRef.get()para singletons ymoduleRef.resolve()para providers request/transient scoped. - Hay tests de integración con providers reales colaborando, mockeando solo el borde de IO externo.
- Los e2e levantan la app con
createNestApplication+app.init()y pegan con Supertest sobreapp.getHttpServer(). - Los e2e cubren caminos de error y de autorización (400, 401, 403, 409), no solo el happy path.
- La persistencia se prueba con Testcontainers (Postgres real efímero), no con mocks del repositorio ni SQLite.
- El estado se aísla entre tests (truncado/
deleteManyo transacción con rollback); nada depende del orden de ejecución. - No hay base de datos compartida entre corridas; cada suite tiene la suya.
- La suite paralela (
--maxWorkers/ Vitest); si es lenta, buscás el estado compartido que la serializa. - Mockeás dependencias externas y lo no determinista (tiempo, aleatorio), y no mockeás tu propia lógica.
- Cerrás recursos en
afterAll(app.close(),prisma.$disconnect(),container.stop()).
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 →.