Vaaaamos con el post número 21 de la serie NestJS y el último del bloque de Testing. En el post anterior montamos tests unitarios con Jest con cada pieza aislada, dependencias mockeadas, milisegundos por test. Hoy vamos al otro extremo que son los tests E2E que le zurran a la API como lo haría un cliente real.
En un test unitario, mockeas la base de datos y verificas que el service llama a findOne. Pero eso no te dice si la query SQL está bien, si la validación del DTO funciona, si el guard bloquea correctamente, si el interceptor transforma la respuesta, si la serialización excluye el password… Los tests E2E verifican que todas las piezas encajan y funcionan como se espera que deben funcionar.
Repasamos toda la base como es habitual con Docker (post 1), controllers (post 2), DI (post 3), módulos (post 4), middleware (post 5), validación (post 6), TypeORM (posts 7-9), seguridad (posts 10-12), funcionalidades avanzadas (posts 13-16), eventos y colas (posts 17-19) y testing unitario (post 20).
EA, amo al lío.

1. Test unitario vs E2E: roles complementarios
Test unitario (post 20):
UsersService.findOne('uuid-1')
→ Mock de Repository devuelve { id: 'uuid-1', name: 'Juan' }
→ ✅ findOne devuelve el usuario
→ Pero... ¿la query SQL está bien? ¿El DTO valida? NO lo sabemos
Test E2E (este post):
GET /users/uuid-1 (con header Authorization: Bearer <jwt>)
→ HTTP Request real
→ Middleware ejecutado
→ Guard verifica JWT
→ Controller llama al Service
→ Service consulta PostgreSQL real
→ Interceptor transforma la respuesta
→ Serialización excluye el password
→ 200 { id: 'uuid-1', name: 'Juan' } (sin password)
→ ✅ Todo el pipeline funciona
Rápido, aislado, sin I/O. Detecta bugs de lógica en una pieza. Mockea todo. Serán el 80% de tus tests.
Lento, integrado, con BD real. Detecta bugs de integración entre piezas. Sin mocks. El 20% de tus tests.
No son sustitutos, son complementarios. Los unitarios te dicen esta pieza funciona y los E2E te dicen todas las piezas encajan.
2. Docker Compose para tests
Los tests E2E necesitan una base de datos separada de la de desarrollo porque NO quieres que los tests borren tus datos de desarrollo:
# docker-compose.test.yml
services:
test-db:
image: postgres:16-alpine
environment:
POSTGRES_USER: test_user
POSTGRES_PASSWORD: test_password
POSTGRES_DB: nestjs_test
ports:
- '5433:5432' # Puerto diferente al de desarrollo (5432)
tmpfs:
- /var/lib/postgresql/data # Datos en memoria → más rápido, se pierden al parar
test-redis:
image: redis:7-alpine
ports:
- '6380:6379' # Puerto diferente al de desarrollo (6379)
Puntos clave aquí:
- Puerto diferente :
5433en vez de5432. Así la BD de test no interfiere con la de desarrollo. tmpfs: Los datos viven en memoria RAM que es más rápido que el disco. Cuando paras el container, los datos desaparecen. Perfecto para tests porque cada sesión empieza limpia.- BD y Redis separados : Si tus tests usan colas (Bull del post 18), también necesitas un Redis de test.
3. Configuración del entorno de test
El fichero .env.test con las variables para la BD de test:
# .env.test
DATABASE_HOST=localhost
DATABASE_PORT=5433
DATABASE_USER=test_user
DATABASE_PASSWORD=test_password
DATABASE_NAME=nestjs_test
REDIS_HOST=localhost
REDIS_PORT=6380
JWT_SECRET=test-secret-key-not-for-production
JWT_EXPIRATION=15m
JWT_REFRESH_EXPIRATION=7d
La configuración de Jest para los unitarios que ya tenías del post 20 en package.json debe seguir apuntando a .spec.ts (no a .e2e-spec.ts), para que npm test solo ejecute unitarios:
{
"jest": {
"moduleFileExtensions": ["js", "json", "ts"],
"rootDir": "src",
"testRegex": ".*\\.spec\\.ts$",
"transform": {
"^.+\\.(t|j)s$": "ts-jest"
},
"testEnvironment": "node"
}
}
Los tests E2E viven en la carpeta test/ (raíz del proyecto), no en src/, y el sufijo es .e2e-spec.ts. Con esto npm test solo encuentra unitarios y npm run test:e2e solo encuentra E2E. Separación limpia.
En package.json:
{
"scripts": {
"test": "jest",
"test:watch": "jest --watch",
"test:cov": "jest --coverage",
"test:e2e": "dotenv -e .env.test -- jest --config ./test/jest-e2e.json --runInBand"
}
}
El dotenv-cli dotenv-cli Herramienta CLI que carga variables de entorno desde un archivo .env antes de ejecutar un comando. Con 'dotenv -e .env.test -- jest' carga las variables de .env.test y luego ejecuta jest. carga .env.test antes de ejecutar Jest. Y --runInBand ejecuta los tests en serie (no en paralelo) para evitar conflictos en la BD compartida.
Crea el archivo de configuración de Jest para E2E:
// test/jest-e2e.json
{
"moduleFileExtensions": ["js", "json", "ts"],
"rootDir": ".",
"testEnvironment": "node",
"testRegex": ".*\\.e2e-spec\\.ts$",
"transform": {
"^.+\\.(t|j)s$": "ts-jest"
},
"moduleNameMapper": {
"^src/(.*)$": "<rootDir>/../src/$1"
}
}
4. El bootstrapping: levantar la app para tests
El fichero central es test/setup.ts que levanta la app una vez para todos los tests E2E:
// test/setup.ts
import { Test, type TestingModule } from '@nestjs/testing';
import { type INestApplication, ValidationPipe } from '@nestjs/common';
import { AppModule } from '../src/app.module';
let app: INestApplication;
export async function createTestApp(): Promise<INestApplication> {
const moduleFixture: TestingModule = await Test.createTestingModule({
imports: [AppModule],
}).compile();
app = moduleFixture.createNestApplication();
// Aplicar los mismos pipes globales que en main.ts
app.useGlobalPipes(
new ValidationPipe({
whitelist: true,
forbidNonWhitelisted: true,
transform: true,
transformOptions: {
enableImplicitConversion: true,
},
})
);
await app.init();
return app;
}
export async function closeTestApp(): Promise<void> {
if (app) {
await app.close();
}
}
export function getApp(): INestApplication {
return app;
}
Diferencia importante con los tests unitarios es que aquí importamos el AppModule real y no mockeamos nada. La app se levanta con todos sus módulos, servicios, guards, interceptors, pipes y la conexión real a PostgreSQL.
app.useGlobalPipes(): Es vital que apliques los mismos pipes globales que en tumain.ts. Si no, los DTOs no se validan y los tests pasan con datos inválidos que en producción fallarían.
5. Database helper: limpiar entre tests
Cada test E2E necesita una base de datos limpia porque no quieres que los datos de un test afecten a otro:
// test/helpers/database.helper.ts
import { type DataSource } from 'typeorm';
import { type INestApplication } from '@nestjs/common';
export async function cleanDatabase(app: INestApplication): Promise<void> {
const dataSource = app.get(DataSource);
// Obtener todas las entidades registradas
const entities = dataSource.entityMetadatas;
for (const entity of entities) {
const repository = dataSource.getRepository(entity.name);
// TRUNCATE es más rápido que DELETE y resetea los sequences
await repository.query(`TRUNCATE TABLE "${entity.tableName}" CASCADE`);
}
}
export async function synchronizeDatabase(app: INestApplication): Promise<void> {
const dataSource = app.get(DataSource);
// DROP + CREATE de todas las tablas según las entidades.
// El 'true' = dropBeforeSync: es destructivo, solo para tests.
await dataSource.synchronize(true);
}
TRUNCATE ... CASCADE borra todos los registros de todas las tablas en cascada. Más rápido que DELETE FROM porque no genera logs de transacción ni activa triggers. El CASCADE maneja las foreign keys automáticamente.
Nota
synchronizeDatabasese llama una vez enbeforeAllpara recrear el esquema;cleanDatabasese llama en cadabeforeEachpara vaciar datos sin tocar el esquema. No usessynchronize(true)en cada test porque es destructivo y mucho más lento queTRUNCATE.
6. Auth helper: register y login tipados
Los tests de endpoints protegidos necesitan un JWT válido. En vez de repetir el flujo de register + login en cada test, extráelo a un helper:
// test/helpers/auth.helper.ts
import request from 'supertest';
import { randomUUID } from 'node:crypto';
import { type INestApplication } from '@nestjs/common';
interface RegisterDto {
readonly email: string;
readonly name: string;
readonly password: string;
}
interface AuthTokens {
readonly accessToken: string;
readonly refreshToken: string;
}
interface AuthenticatedUser {
readonly tokens: AuthTokens;
readonly userId: string;
}
const defaultPassword = 'TestPass123!';
export async function registerUser(
app: INestApplication,
overrides: Partial<RegisterDto> = {}
): Promise<AuthenticatedUser> {
const dto: RegisterDto = {
email: `test-${randomUUID()}@test.com`,
name: 'Test User',
password: defaultPassword,
...overrides,
};
const response = await request(app.getHttpServer()).post('/auth/register').send(dto).expect(201);
return {
tokens: {
accessToken: response.body.accessToken,
refreshToken: response.body.refreshToken,
},
userId: response.body.userId,
};
}
export async function loginUser(app: INestApplication, email: string, password = defaultPassword): Promise<AuthTokens> {
const response = await request(app.getHttpServer()).post('/auth/login').send({ email, password }).expect(200);
return {
accessToken: response.body.accessToken,
refreshToken: response.body.refreshToken,
};
}
export async function registerAndGetToken(
app: INestApplication,
overrides: Partial<RegisterDto> = {}
): Promise<string> {
const { tokens } = await registerUser(app, overrides);
return tokens.accessToken;
}
Fíjate en que randomUUID() en el email garantiza que cada test usa un email único. Si usaras Date.now() en su lugar, dos llamadas muy rápidas dentro del mismo milisegundo (algo habitual en tests) pueden devolver el mismo valor y provocar colisiones de email. randomUUID() no tiene ese problema.
7. Tu primer test E2E
💡 Supertest Librería de Node.js para testear APIs HTTP. Se integra con el servidor HTTP de tu app sin necesidad de levantar un puerto real. Encadena llamadas fluidas: request(server).get('/users').set('Authorization', token).expect(200).
Más info →
viene como devDependency en los proyectos generados con @nestjs/cli (nest new). Si arrancas de un template minimalista o lo añades a un proyecto existente, instálalo con npm i -D supertest @types/supertest:
// test/users.e2e-spec.ts
import request from 'supertest';
import { type INestApplication } from '@nestjs/common';
import { createTestApp, closeTestApp, getApp } from './setup';
import { cleanDatabase, synchronizeDatabase } from './helpers/database.helper';
import { registerAndGetToken, registerUser } from './helpers/auth.helper';
describe('UsersController (e2e)', () => {
let app: INestApplication;
let accessToken: string;
beforeAll(async () => {
app = await createTestApp();
await synchronizeDatabase(app);
});
afterAll(async () => {
await closeTestApp();
});
beforeEach(async () => {
await cleanDatabase(app);
// Registrar un usuario y obtener su token para cada test
accessToken = await registerAndGetToken(app);
});
describe('GET /users/:id', () => {
it('should return a user by id', async () => {
const { userId } = await registerUser(app, {
email: 'juan@test.com',
name: 'Juan García',
});
const response = await request(app.getHttpServer())
.get(`/users/${userId}`)
.set('Authorization', `Bearer ${accessToken}`)
.expect(200);
expect(response.body).toHaveProperty('id', userId);
expect(response.body).toHaveProperty('name', 'Juan García');
expect(response.body).toHaveProperty('email', 'juan@test.com');
// La serialización del post 15 debe excluir el password
expect(response.body).not.toHaveProperty('password');
});
it('should return 404 for non-existent user', async () => {
const fakeId = '00000000-0000-0000-0000-000000000000';
const response = await request(app.getHttpServer())
.get(`/users/${fakeId}`)
.set('Authorization', `Bearer ${accessToken}`)
.expect(404);
expect(response.body).toHaveProperty('message');
expect(response.body.message).toContain(fakeId);
});
it('should return 401 without auth token', async () => {
await request(app.getHttpServer()).get('/users/any-id').expect(401);
});
it('should return 400 for invalid UUID format', async () => {
await request(app.getHttpServer())
.get('/users/not-a-uuid')
.set('Authorization', `Bearer ${accessToken}`)
.expect(400);
});
});
});
Desglose del asunto:
request(app.getHttpServer()): Supertest se conecta al servidor HTTP de NestJS y no necesita un puerto real..get('/users/uuid'): Hace un GET real al endpoint..set('Authorization', ...): Envía el header de autenticación (JWT del post 10)..expect(200): Verifica el status code HTTP.response.body: El JSON parseado de la respuesta.beforeEach → cleanDatabase(): Cada test empieza con la BD vacía, así tienes tests independientes.not.toHaveProperty('password'): Verifica que la serialización del post 15 funciona.
8. Flujo completo: Register → Login → CRUD
El test más valioso es el flow completo de un usuario real.
// test/auth-flow.e2e-spec.ts
import request from 'supertest';
import { type INestApplication } from '@nestjs/common';
import { createTestApp, closeTestApp } from './setup';
import { cleanDatabase, synchronizeDatabase } from './helpers/database.helper';
describe('Auth Flow (e2e)', () => {
let app: INestApplication;
beforeAll(async () => {
app = await createTestApp();
await synchronizeDatabase(app);
});
afterAll(async () => {
await closeTestApp();
});
beforeEach(async () => {
await cleanDatabase(app);
});
it('should complete the full auth lifecycle', async () => {
// 1. REGISTER
const registerResponse = await request(app.getHttpServer())
.post('/auth/register')
.send({
email: 'nuevo@test.com',
name: 'Nuevo Usuario',
password: 'SecurePass123!',
})
.expect(201);
expect(registerResponse.body).toHaveProperty('accessToken');
expect(registerResponse.body).toHaveProperty('refreshToken');
expect(registerResponse.body).toHaveProperty('userId');
const { accessToken, refreshToken, userId } = registerResponse.body;
// 2. GET PROFILE con el token del registro
const profileResponse = await request(app.getHttpServer())
.get(`/users/${userId}`)
.set('Authorization', `Bearer ${accessToken}`)
.expect(200);
expect(profileResponse.body.email).toBe('nuevo@test.com');
expect(profileResponse.body.name).toBe('Nuevo Usuario');
expect(profileResponse.body).not.toHaveProperty('password');
// 3. LOGIN con las mismas credenciales
const loginResponse = await request(app.getHttpServer())
.post('/auth/login')
.send({
email: 'nuevo@test.com',
password: 'SecurePass123!',
})
.expect(200);
expect(loginResponse.body).toHaveProperty('accessToken');
const newAccessToken = loginResponse.body.accessToken;
// 4. UPDATE PROFILE con el nuevo token
const updateResponse = await request(app.getHttpServer())
.patch(`/users/${userId}`)
.set('Authorization', `Bearer ${newAccessToken}`)
.send({ name: 'Nombre Actualizado' })
.expect(200);
expect(updateResponse.body.name).toBe('Nombre Actualizado');
// 5. REFRESH TOKEN
const refreshResponse = await request(app.getHttpServer())
.post('/auth/refresh')
.send({ refreshToken })
.expect(200);
expect(refreshResponse.body).toHaveProperty('accessToken');
// 6. LOGOUT (invalidar refresh token)
await request(app.getHttpServer())
.post('/auth/logout')
.set('Authorization', `Bearer ${refreshResponse.body.accessToken}`)
.expect(200);
// 7. El refresh token antiguo ya no debería funcionar
await request(app.getHttpServer()).post('/auth/refresh').send({ refreshToken }).expect(401);
});
});
Un solo test que recorre todo el ciclo de vida de autenticación: register, profile, login, update, refresh, logout y verificación de que el token invalidado ya no funciona. Si cualquier pieza del pipeline falla, este test te lo dice.
9. Testear validación de DTOs
Los DTOs del post 6 se validan con ValidationPipe. Los tests E2E verifican que la validación funciona de verdad:
// test/validation.e2e-spec.ts
describe('DTO Validation (e2e)', () => {
// ... setup
describe('POST /auth/register', () => {
it('should reject registration without email', async () => {
const response = await request(app.getHttpServer())
.post('/auth/register')
.send({
name: 'Test',
password: 'Pass123!',
// email falta
})
.expect(400);
expect(response.body.message).toContain('email');
});
it('should reject registration with invalid email format', async () => {
await request(app.getHttpServer())
.post('/auth/register')
.send({
email: 'not-an-email',
name: 'Test',
password: 'Pass123!',
})
.expect(400);
});
it('should reject registration with short password', async () => {
await request(app.getHttpServer())
.post('/auth/register')
.send({
email: 'test@test.com',
name: 'Test',
password: '123', // Demasiado corto
})
.expect(400);
});
it('should reject unknown fields (forbidNonWhitelisted)', async () => {
const response = await request(app.getHttpServer())
.post('/auth/register')
.send({
email: 'test@test.com',
name: 'Test',
password: 'Pass123!',
isAdmin: true, // Campo no definido en el DTO
})
.expect(400);
expect(response.body.message).toContain('property isAdmin should not exist');
});
it('should reject duplicate email', async () => {
// Registrar una vez
await request(app.getHttpServer())
.post('/auth/register')
.send({
email: 'duplicado@test.com',
name: 'Primero',
password: 'Pass123!',
})
.expect(201);
// Intentar registrar con el mismo email
await request(app.getHttpServer())
.post('/auth/register')
.send({
email: 'duplicado@test.com',
name: 'Segundo',
password: 'Pass123!',
})
.expect(409); // Conflict
});
});
});
El test de forbidNonWhitelisted es especialmente importante porque verifica que un atacante no puede enviar campos extra (como isAdmin: true) para escalar privilegios. Esto lo configuramos en el post 6 y aquí verificamos que funciona end-to-end.
10. Testear autorización y roles
Los guards del post 11 verificados E2E:
// test/authorization.e2e-spec.ts
describe('Authorization (e2e)', () => {
// ... setup
describe('Admin-only endpoints', () => {
it('should allow admin to access admin endpoint', async () => {
// Registrar un admin (necesitas un seeder o un endpoint de admin)
const adminToken = await createAdminUser(app);
await request(app.getHttpServer())
.get('/admin/users')
.set('Authorization', `Bearer ${adminToken}`)
.expect(200);
});
it('should deny regular user access to admin endpoint', async () => {
const userToken = await registerAndGetToken(app);
await request(app.getHttpServer())
.get('/admin/users')
.set('Authorization', `Bearer ${userToken}`)
.expect(403); // Forbidden
});
it('should deny unauthenticated access to admin endpoint', async () => {
await request(app.getHttpServer()).get('/admin/users').expect(401); // Unauthorized
});
});
describe('Resource ownership', () => {
it('should allow user to update their own profile', async () => {
const { tokens, userId } = await registerUser(app);
await request(app.getHttpServer())
.patch(`/users/${userId}`)
.set('Authorization', `Bearer ${tokens.accessToken}`)
.send({ name: 'Updated' })
.expect(200);
});
it('should deny user from updating another users profile', async () => {
const user1 = await registerUser(app, { email: 'user1@test.com' });
const user2 = await registerUser(app, { email: 'user2@test.com' });
await request(app.getHttpServer())
.patch(`/users/${user2.userId}`)
.set('Authorization', `Bearer ${user1.tokens.accessToken}`)
.send({ name: 'Hacked' })
.expect(403);
});
});
});
// test/helpers/auth.helper.ts (añadir al helper existente)
export async function createAdminUser(app: INestApplication): Promise<string> {
const dataSource = app.get(DataSource);
const usersRepository = dataSource.getRepository('User');
// Registrar usuario normal
const { tokens, userId } = await registerUser(app, {
email: 'admin@test.com',
name: 'Admin User',
});
// Promover a admin directamente en la BD
await usersRepository.update(userId, { roles: ['admin', 'user'] });
// Re-login para obtener un token con el rol actualizado
const adminTokens = await loginUser(app, 'admin@test.com');
return adminTokens.accessToken;
}
El helper createAdminUser registra un usuario, lo promueve a admin directamente en la BD (sin endpoint público), y hace login de nuevo para obtener un JWT con el rol admin incluido.
11. Testear CRUD completo
// test/posts-crud.e2e-spec.ts
describe('Posts CRUD (e2e)', () => {
let app: INestApplication;
let accessToken: string;
beforeAll(async () => {
app = await createTestApp();
await synchronizeDatabase(app);
});
afterAll(async () => {
await closeTestApp();
});
beforeEach(async () => {
await cleanDatabase(app);
accessToken = await registerAndGetToken(app);
});
it('should perform full CRUD on posts', async () => {
// CREATE
const createResponse = await request(app.getHttpServer())
.post('/posts')
.set('Authorization', `Bearer ${accessToken}`)
.send({
title: 'Mi primer post',
content: 'Contenido del post',
tags: ['nestjs', 'typescript'],
})
.expect(201);
const postId = createResponse.body.id;
expect(createResponse.body.title).toBe('Mi primer post');
// READ ONE
const readResponse = await request(app.getHttpServer())
.get(`/posts/${postId}`)
.set('Authorization', `Bearer ${accessToken}`)
.expect(200);
expect(readResponse.body.title).toBe('Mi primer post');
expect(readResponse.body.tags).toEqual(['nestjs', 'typescript']);
// READ ALL (con paginación del post 8)
const listResponse = await request(app.getHttpServer())
.get('/posts?page=1&limit=10')
.set('Authorization', `Bearer ${accessToken}`)
.expect(200);
expect(listResponse.body.data).toHaveLength(1);
expect(listResponse.body.meta).toHaveProperty('total', 1);
expect(listResponse.body.meta).toHaveProperty('page', 1);
// UPDATE
const updateResponse = await request(app.getHttpServer())
.patch(`/posts/${postId}`)
.set('Authorization', `Bearer ${accessToken}`)
.send({ title: 'Título actualizado' })
.expect(200);
expect(updateResponse.body.title).toBe('Título actualizado');
expect(updateResponse.body.content).toBe('Contenido del post'); // No cambió
// DELETE (soft delete del post 8)
await request(app.getHttpServer())
.delete(`/posts/${postId}`)
.set('Authorization', `Bearer ${accessToken}`)
.expect(200);
// Verificar que ya no aparece en el listado
const afterDelete = await request(app.getHttpServer())
.get('/posts?page=1&limit=10')
.set('Authorization', `Bearer ${accessToken}`)
.expect(200);
expect(afterDelete.body.data).toHaveLength(0);
// Pero el GET directo devuelve 404 (soft deleted)
await request(app.getHttpServer())
.get(`/posts/${postId}`)
.set('Authorization', `Bearer ${accessToken}`)
.expect(404);
});
});
Aquí se testea el CRUD completo del post 8 incluyendo la paginación y el soft delete. Si la query de paginación tiene un bug, si el soft delete no filtra correctamente, o si el update sobreescribe campos que no debería, este test te lo dice.
12. Fixtures: datos de prueba reutilizables
Para tests más complejos, crea 💡 fixtures Datos de prueba predefinidos y reutilizables que se insertan en la BD antes de ejecutar los tests. Proporcionan un estado conocido y reproducible. En vez de crear datos en cada test, cargas las fixtures una vez. Más info → con datos tipados:
// test/fixtures/users.fixture.ts
import { type DataSource } from 'typeorm';
import * as bcrypt from 'bcrypt';
interface UserFixture {
readonly id: string;
readonly email: string;
readonly name: string;
readonly roles: ReadonlyArray<string>;
}
const PASSWORD_HASH = bcrypt.hashSync('TestPass123!', 10);
export const userFixtures: ReadonlyArray<UserFixture> = [
{
id: '11111111-1111-1111-1111-111111111111',
email: 'admin@fixture.com',
name: 'Admin Fixture',
roles: ['admin', 'user'],
},
{
id: '22222222-2222-2222-2222-222222222222',
email: 'user@fixture.com',
name: 'User Fixture',
roles: ['user'],
},
{
id: '33333333-3333-3333-3333-333333333333',
email: 'editor@fixture.com',
name: 'Editor Fixture',
roles: ['editor', 'user'],
},
];
export async function seedUsers(dataSource: DataSource): Promise<void> {
const repository = dataSource.getRepository('User');
for (const user of userFixtures) {
await repository.save({
...user,
password: PASSWORD_HASH,
});
}
}
// test/fixtures/posts.fixture.ts
import { type DataSource } from 'typeorm';
interface PostFixture {
readonly id: string;
readonly title: string;
readonly content: string;
readonly userId: string;
readonly tags: ReadonlyArray<string>;
}
export const postFixtures: ReadonlyArray<PostFixture> = [
{
id: 'aaaaaaaa-aaaa-4aaa-aaaa-aaaaaaaaaaaa',
title: 'Post de Admin',
content: 'Contenido admin',
userId: '11111111-1111-1111-1111-111111111111',
tags: ['admin'],
},
{
id: 'bbbbbbbb-bbbb-4bbb-bbbb-bbbbbbbbbbbb',
title: 'Post de Usuario',
content: 'Contenido usuario',
userId: '22222222-2222-2222-2222-222222222222',
tags: ['general'],
},
];
export async function seedPosts(dataSource: DataSource): Promise<void> {
const repository = dataSource.getRepository('Post');
for (const post of postFixtures) {
await repository.save(post);
}
}
// test/fixtures/index.ts
import { type DataSource } from 'typeorm';
import { seedUsers } from './users.fixture';
import { seedPosts } from './posts.fixture';
export async function seedAll(dataSource: DataSource): Promise<void> {
// Orden importa: usuarios primero (posts tienen FK a users)
await seedUsers(dataSource);
await seedPosts(dataSource);
}
Uso en los tests:
import { DataSource } from 'typeorm';
import { seedAll } from './fixtures';
import { userFixtures } from './fixtures/users.fixture';
beforeEach(async () => {
await cleanDatabase(app);
const dataSource = app.get(DataSource);
await seedAll(dataSource);
});
it('should list posts with fixture data', async () => {
const adminToken = await loginUser(app, 'admin@fixture.com');
const response = await request(app.getHttpServer())
.get('/posts')
.set('Authorization', `Bearer ${adminToken}`)
.expect(200);
expect(response.body.data).toHaveLength(2);
});
IDs fijos en las fixtures (11111111-...) para poder referenciarlos en los tests sin buscar. El bcrypt.hashSync se ejecuta una sola vez al importar el módulo, no en cada test.
13. Rate limiting E2E
Verificar que el rate limiting rate limiting Límite de peticiones por ventana de tiempo. Configurado con @nestjs/throttler en el post 12. Los tests E2E verifican que el límite funciona enviando más peticiones de las permitidas. del post 12 funciona. Dos consideraciones antes del código:
- Ajusta los números al límite real configurado en el
ThrottlerModule. Si el límite es10/ 60s, disparalimit + Npeticiones; si es100/ 60s, disparalimit + Npeticiones. Hardcodear15y cruzar los dedos es flaky. - Resetea el storage del throttler entre tests o aísla este spec. El storage por defecto es in-memory y persiste entre tests dentro del mismo proceso, así que el orden de ejecución afecta el resultado. Con
ThrottlerStorageServiceexpuesto puedes limpiarlo enbeforeEach.
// test/rate-limiting.e2e-spec.ts
import { ThrottlerStorage } from '@nestjs/throttler';
describe('Rate Limiting (e2e)', () => {
// ... setup
// Ajusta estos valores al límite real del ThrottlerModule del post 12
const LIMIT = 10;
const EXTRA = 5;
beforeEach(async () => {
await cleanDatabase(app);
// Resetear el storage in-memory del throttler para que cada test arranque limpio
const storage = app.get(ThrottlerStorage);
// @ts-expect-error: la API interna varía según la versión; ajusta al método disponible
storage.storage = {};
});
it('should block requests after exceeding the limit', async () => {
const accessToken = await registerAndGetToken(app);
// Enviar LIMIT + EXTRA peticiones secuencialmente, no en paralelo:
// Promise.all envía todas "a la vez" y algunas pueden entrar antes de
// que el throttler incremente su contador → contajes no deterministas.
const statuses: number[] = [];
for (let i = 0; i < LIMIT + EXTRA; i++) {
const response = await request(app.getHttpServer())
.get('/users/me')
.set('Authorization', `Bearer ${accessToken}`);
statuses.push(response.status);
}
const successCount = statuses.filter((s) => s === 200).length;
const blockedCount = statuses.filter((s) => s === 429).length;
expect(successCount).toBe(LIMIT);
expect(blockedCount).toBe(EXTRA);
});
});
AdvertenciaEl throttler identifica al cliente por IP por defecto. Con
supertesttodas las peticiones vienen del mismo host, así que suma bien los contadores. Si en producción usastrust proxypara leerX-Forwarded-For, revisa que también esté activo en tests o el test medirá otra cosa que en prod.
14. Custom matchers: hacer los tests más expresivos
Para tests E2E repetitivos, crea matchers custom de Jest:
// test/helpers/custom-matchers.ts
declare global {
// eslint-disable-next-line @typescript-eslint/no-namespace
namespace jest {
interface Matchers<R> {
toBeValidUUID(): R;
toBeISODateString(): R;
}
}
}
expect.extend({
toBeValidUUID(received: unknown) {
const uuidRegex = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
const pass = typeof received === 'string' && uuidRegex.test(received);
return {
pass,
message: () => `expected ${received} to ${pass ? 'not ' : ''}be a valid UUID v4`,
};
},
toBeISODateString(received: unknown) {
const pass = typeof received === 'string' && !isNaN(Date.parse(received)) && received.includes('T');
return {
pass,
message: () => `expected ${received} to ${pass ? 'not ' : ''}be a valid ISO date string`,
};
},
});
export {};
Uso:
expect(response.body.id).toBeValidUUID();
expect(response.body.createdAt).toBeISODateString();
Registra los matchers en test/jest-e2e.json usando setupFilesAfterEnv (la opción válida de Jest; setupFilesAfterFramework no existe y Jest la ignora silenciosamente, dejándote con toBeValidUUID is not a function):
{
"setupFilesAfterEnv": ["./helpers/custom-matchers.ts"]
}
15. CI Pipeline con GitHub Actions
Todo esto funcionando en CI automáticamente en cada push:
# .github/workflows/test.yml
name: Tests
on:
push:
branches: [main, develop]
pull_request:
branches: [main]
jobs:
unit-tests:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: '20'
cache: 'npm'
- run: npm ci
- name: Run unit tests
run: npm test -- --coverage
- name: Upload coverage
uses: actions/upload-artifact@v4
with:
name: coverage
path: coverage/
e2e-tests:
runs-on: ubuntu-latest
services:
postgres:
image: postgres:16-alpine
env:
POSTGRES_USER: test_user
POSTGRES_PASSWORD: test_password
POSTGRES_DB: nestjs_test
ports:
- 5433:5432
options: >-
--health-cmd pg_isready
--health-interval 10s
--health-timeout 5s
--health-retries 5
redis:
image: redis:7-alpine
ports:
- 6380:6379
options: >-
--health-cmd "redis-cli ping"
--health-interval 10s
--health-timeout 5s
--health-retries 5
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: '20'
cache: 'npm'
- run: npm ci
- name: Run E2E tests
run: npm run test:e2e
env:
DATABASE_HOST: localhost
DATABASE_PORT: 5433
DATABASE_USER: test_user
DATABASE_PASSWORD: test_password
DATABASE_NAME: nestjs_test
REDIS_HOST: localhost
REDIS_PORT: 6380
JWT_SECRET: ci-test-secret
JWT_EXPIRATION: 15m
JWT_REFRESH_EXPIRATION: 7d
Puntos clave aquí:
- Dos jobs separados :
unit-testsye2e-testscorren en paralelo. Si los unitarios fallan rápido, no esperas a que levante PostgreSQL. services: GitHub Actions levanta PostgreSQL y Redis como containers. Mismo concepto que nuestrodocker-compose.test.yml.health-cmd: GitHub Actions espera a que PostgreSQL y Redis estén listos antes de ejecutar los tests.pg_isreadyyredis-cli pingson los health checks.npm cien vez denpm install: Instalación determinista desdepackage-lock.json. Más rápida y reproducible en CI.
16. Ejecutar los tests
17. Estructura de archivos
project-root/
├── src/ ← Código fuente
│ ├── users/
│ │ ├── users.service.ts
│ │ └── users.service.spec.ts ← Tests unitarios (post 20)
│ └── auth/
│ ├── auth.service.ts
│ └── auth.service.spec.ts
├── test/ ← Tests E2E (este post)
│ ├── jest-e2e.json ← Config de Jest para E2E
│ ├── setup.ts ← Bootstrapping de la app
│ ├── helpers/
│ │ ├── database.helper.ts ← cleanDatabase, synchronizeDatabase
│ │ ├── auth.helper.ts ← registerUser, loginUser, createAdminUser
│ │ └── custom-matchers.ts ← toBeValidUUID, toBeISODateString
│ ├── fixtures/
│ │ ├── index.ts ← seedAll (orquesta el orden)
│ │ ├── users.fixture.ts ← Datos de usuarios de prueba
│ │ └── posts.fixture.ts ← Datos de posts de prueba
│ ├── auth-flow.e2e-spec.ts ← Flujo completo de auth
│ ├── users.e2e-spec.ts ← CRUD de usuarios
│ ├── posts-crud.e2e-spec.ts ← CRUD de posts
│ ├── validation.e2e-spec.ts ← Validación de DTOs
│ ├── authorization.e2e-spec.ts ← Guards y roles
│ └── rate-limiting.e2e-spec.ts ← Rate limiting
├── .env.test ← Variables de entorno para tests
├── docker-compose.test.yml ← BD y Redis para tests
└── .github/
└── workflows/
└── test.yml ← CI pipeline
Separación clara con los tests unitarios en src/, E2E en test/. Los helpers y fixtures tienen su carpeta y cada spec cubre una funcionalidad.
18. Errores comunes
Error 1: No limpiar la BD entre tests
// ❌ Sin cleanDatabase: los datos de un test contaminan al siguiente
beforeEach(async () => {
// nada : los datos del test anterior siguen ahí
accessToken = await registerAndGetToken(app);
// Puede fallar con "email already exists" si el test anterior registró el mismo email
});
// ✅ Limpiar la BD antes de cada test
beforeEach(async () => {
await cleanDatabase(app);
accessToken = await registerAndGetToken(app);
});
Error 2: No aplicar los mismos pipes globales que en main.ts
// ❌ La app de test no tiene ValidationPipe → los DTOs no se validan
const app = moduleFixture.createNestApplication();
await app.init();
// POST con datos inválidos devuelve 201 en vez de 400
// ✅ Aplicar los mismos pipes que en producción
const app = moduleFixture.createNestApplication();
app.useGlobalPipes(
new ValidationPipe({
whitelist: true,
forbidNonWhitelisted: true,
transform: true,
})
);
await app.init();
Error 3: Tests que dependen del orden de ejecución
// ❌ Test B asume que test A creó un usuario
it('A: create user', async () => {
await request(app.getHttpServer())
.post('/auth/register')
.send({ email: 'test@test.com', name: 'Test', password: 'Pass123!' });
});
it('B: login with created user', async () => {
// Falla si test A no se ejecutó primero, o si se ejecutaron en paralelo
await request(app.getHttpServer())
.post('/auth/login')
.send({ email: 'test@test.com', password: 'Pass123!' })
.expect(200);
});
// ✅ Cada test crea sus propios datos
it('should login with valid credentials', async () => {
await request(app.getHttpServer())
.post('/auth/register')
.send({ email: 'test@test.com', name: 'Test', password: 'Pass123!' });
await request(app.getHttpServer())
.post('/auth/login')
.send({ email: 'test@test.com', password: 'Pass123!' })
.expect(200);
});
Error 4: Ejecutar E2E en paralelo con BD compartida
// ❌ --runInBand ausente: Jest ejecuta specs en paralelo
// Spec A y Spec B hacen TRUNCATE al mismo tiempo → race conditions
"test:e2e": "jest --config ./test/jest-e2e.json"
// ✅ --runInBand: ejecuta specs en serie
"test:e2e": "jest --config ./test/jest-e2e.json --runInBand"
Error 5: Hardcodear tokens en los tests
// ❌ Token hardcodeado: caduca, se invalida, no es reproducible
const token = 'eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiIxIiwiZXhwIjoxNzA...';
await request(app.getHttpServer()).get('/users/1').set('Authorization', `Bearer ${token}`);
// ✅ Generar el token dinámicamente en cada test
const token = await registerAndGetToken(app);
await request(app.getHttpServer()).get('/users/me').set('Authorization', `Bearer ${token}`).expect(200);
19. Recapitulando
request(app.getHttpServer()).get().set().expect(). HTTP real sin puerto. Verifica status codes, headers y body.
docker-compose.test.yml con PostgreSQL y Redis en puertos separados. tmpfs para datos en memoria. Rápido y aislado.
TRUNCATE CASCADE entre tests. Cada test empieza con la BD vacía. Tests independientes, sin contaminación.
registerUser(), loginUser(), createAdminUser(). Encapsulan flujos repetitivos. Cada test en 3-5 líneas.
Datos predefinidos con IDs fijos. seedUsers → seedPosts (orden de FKs). Estado conocido y reproducible.
GitHub Actions con services de PostgreSQL y Redis. Unit tests y E2E en paralelo. Health checks antes de ejecutar.
Y con esto cerramos el Bloque 6: Testing.
- Tests unitarios (post 20) : Aislados, mockeados, milisegundos. Detectan bugs de lógica.
- Tests E2E (post 21) : Integrados, BD real, segundos. Detectan bugs de integración.
En el próximo post entramos en el Bloque 7: Escala Enterprise con GraphQL Code-First: @nestjs/graphql, @Resolver, @Query, @Mutation, @ObjectType, @Field, subscriptions, dataloaders y REST vs GraphQL.
EA, nos vemos en los bares!! 🍺
Pon a prueba lo aprendido
1. ¿Por qué los tests E2E necesitan una base de datos separada de la de desarrollo?
2. ¿Por qué se usa TRUNCATE CASCADE en vez de DELETE FROM para limpiar la BD entre tests?
3. ¿Por qué es crucial aplicar los mismos pipes globales (ValidationPipe) en la app de test que en main.ts?
4. ¿Para qué sirve el flag --runInBand al ejecutar tests E2E?
5. ¿Qué verifica un test E2E que un test unitario NO puede verificar?