🚨 ¡Nueva review! ✨ Mi ratón favorito para programar: el Logitech MX Master 3S . ¡Échale un ojo! 👀

Testing E2E con Supertest en NestJS

Serie NestJS #21 : Supertest, app bootstrapping en tests, BD de test en Docker, fixtures, flujos completos register → login → CRUD y CI pipeline

Escrito por domin el 19 de abril de 2026

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.

Diagrama de tests E2E recorriendo el flujo completo HTTP → Controller → Service → BD real en NestJS con Supertest.

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
🔬 Test unitario (post 20)

Rápido, aislado, sin I/O. Detecta bugs de lógica en una pieza. Mockea todo. Serán el 80% de tus tests.

🔭 Test E2E (este post)

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í:

Levantar los containers de test 0 / 2
$
Pulsa para ejecutar el siguiente comando

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"
    }
}
Instalación de dotenv-cli 0 / 1
$
Pulsa para ejecutar el siguiente comando

El dotenv-cli 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 tu main.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

synchronizeDatabase se llama una vez en beforeAll para recrear el esquema; cleanDatabase se llama en cada beforeEach para vaciar datos sin tocar el esquema. No uses synchronize(true) en cada test porque es destructivo y mucho más lento que TRUNCATE.


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

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:


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 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 del post 12 funciona. Dos consideraciones antes del código:

// 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);
    });
});
Advertencia

El throttler identifica al cliente por IP por defecto. Con supertest todas las peticiones vienen del mismo host, así que suma bien los contadores. Si en producción usas trust proxy para leer X-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í:


16. Ejecutar los tests

Ejecutar tests E2E 0 / 3
$
Pulsa para ejecutar el siguiente comando

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

🧪 Supertest

request(app.getHttpServer()).get().set().expect(). HTTP real sin puerto. Verifica status codes, headers y body.

🐳 BD de test en Docker

docker-compose.test.yml con PostgreSQL y Redis en puertos separados. tmpfs para datos en memoria. Rápido y aislado.

🧹 cleanDatabase()

TRUNCATE CASCADE entre tests. Cada test empieza con la BD vacía. Tests independientes, sin contaminación.

🔧 Helpers tipados

registerUser(), loginUser(), createAdminUser(). Encapsulan flujos repetitivos. Cada test en 3-5 líneas.

📦 Fixtures

Datos predefinidos con IDs fijos. seedUsers → seedPosts (orden de FKs). Estado conocido y reproducible.

🚀 CI Pipeline

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.

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?