# Testing en NestJS — Jest, Mocks y e2e

## Test.createTestingModule completo

```typescript
// users/users.service.spec.ts
import { Test, TestingModule } from '@nestjs/testing';
import { getRepositoryToken } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { ConflictException, NotFoundException } from '@nestjs/common';
import { UsersService } from './users.service';
import { User } from './entities/user.entity';
import type { CreateUserDto } from './dto/create-user.dto';

// Tipo helper: todos los métodos del repositorio como jest.fn()
type MockRepository<T> = Partial<Record<keyof Repository<T>, jest.Mock>>;

const crearMockRepositorio = <T>(): MockRepository<T> => ({
  findOneBy: jest.fn(),
  create: jest.fn(),
  save: jest.fn(),
  find: jest.fn(),
  findAndCount: jest.fn(),
  delete: jest.fn(),
});

describe('UsersService', () => {
  let service: UsersService;
  let repo: MockRepository<User>;

  beforeEach(async () => {
    const module: TestingModule = await Test.createTestingModule({
      providers: [
        UsersService,
        {
          provide: getRepositoryToken(User),
          useValue: crearMockRepositorio<User>(),
        },
      ],
    }).compile();

    service = module.get<UsersService>(UsersService);
    repo = module.get<MockRepository<User>>(getRepositoryToken(User));
  });

  afterEach(() => jest.clearAllMocks());

  describe('crear', () => {
    it('crea un usuario cuando el correo no existe', async () => {
      const dto: CreateUserDto = {
        email: 'usuario@ejemplo.com',
        password: 'segura1234',
        nombre: 'Ana García',
      };
      const usuarioGuardado = { id: 'uuid-1', ...dto, password: 'hash' };

      repo.findOneBy!.mockResolvedValue(null);
      repo.create!.mockReturnValue(usuarioGuardado);
      repo.save!.mockResolvedValue(usuarioGuardado);

      const resultado = await service.crear(dto);

      expect(repo.findOneBy).toHaveBeenCalledWith({ email: dto.email });
      expect(resultado.email).toBe(dto.email);
      expect(resultado.password).not.toBe(dto.password); // debe estar hasheada
    });

    it('lanza ConflictException si el correo ya existe', async () => {
      repo.findOneBy!.mockResolvedValue({ id: 'uuid-1', email: 'ya@existe.com' });

      await expect(
        service.crear({ email: 'ya@existe.com', password: 'segura1234' }),
      ).rejects.toThrow(ConflictException);
    });
  });

  describe('obtenerPorId', () => {
    it('retorna el usuario cuando existe', async () => {
      const usuario = { id: 'uuid-1', email: 'usuario@ejemplo.com' };
      repo.findOneBy!.mockResolvedValue(usuario);

      const resultado = await service.obtenerPorId('uuid-1');
      expect(resultado).toEqual(usuario);
    });

    it('lanza NotFoundException cuando no existe', async () => {
      repo.findOneBy!.mockResolvedValue(null);

      await expect(service.obtenerPorId('uuid-inexistente')).rejects.toThrow(
        NotFoundException,
      );
    });
  });
});
```

---

## Test de controller con service mockeado

```typescript
// users/users.controller.spec.ts
import { Test, TestingModule } from '@nestjs/testing';
import { UsersController } from './users.controller';
import { UsersService } from './users.service';
import type { CreateUserDto } from './dto/create-user.dto';

const mockUsersService = {
  crear: jest.fn(),
  obtenerPorId: jest.fn(),
  listar: jest.fn(),
};

describe('UsersController', () => {
  let controller: UsersController;

  beforeEach(async () => {
    const module: TestingModule = await Test.createTestingModule({
      controllers: [UsersController],
      providers: [
        { provide: UsersService, useValue: mockUsersService },
      ],
    }).compile();

    controller = module.get<UsersController>(UsersController);
  });

  afterEach(() => jest.clearAllMocks());

  it('delega crear() al servicio', async () => {
    const dto: CreateUserDto = { email: 'test@ejemplo.com', password: 'segura1234' };
    const expected = { id: 'uuid-1', email: dto.email };
    mockUsersService.crear.mockResolvedValue(expected);

    const resultado = await controller.crear(dto);

    expect(mockUsersService.crear).toHaveBeenCalledWith(dto);
    expect(resultado).toEqual(expected);
  });
});
```

---

## Test de guard con ExecutionContext mock

```typescript
// common/guards/roles.guard.spec.ts
import { Reflector } from '@nestjs/core';
import { ExecutionContext, ForbiddenException } from '@nestjs/common';
import { RolesGuard } from './roles.guard';

const crearContextoMock = (userRol: string, handler = jest.fn()): ExecutionContext => ({
  getHandler: () => handler,
  getClass: jest.fn(),
  switchToHttp: () => ({
    getRequest: () => ({ user: { rol: userRol } }),
  }),
}) as unknown as ExecutionContext;

describe('RolesGuard', () => {
  let guard: RolesGuard;
  let reflector: Reflector;

  beforeEach(() => {
    reflector = new Reflector();
    guard = new RolesGuard(reflector);
  });

  it('permite acceso cuando no hay roles definidos', () => {
    jest.spyOn(reflector, 'getAllAndOverride').mockReturnValue(undefined);
    const contexto = crearContextoMock('LECTOR');
    expect(guard.canActivate(contexto)).toBe(true);
  });

  it('permite acceso cuando el usuario tiene el rol requerido', () => {
    jest.spyOn(reflector, 'getAllAndOverride').mockReturnValue(['ADMIN']);
    const contexto = crearContextoMock('ADMIN');
    expect(guard.canActivate(contexto)).toBe(true);
  });

  it('lanza ForbiddenException cuando el usuario no tiene el rol', () => {
    jest.spyOn(reflector, 'getAllAndOverride').mockReturnValue(['ADMIN']);
    const contexto = crearContextoMock('LECTOR');
    expect(() => guard.canActivate(contexto)).toThrow(ForbiddenException);
  });
});
```

---

## e2e con Supertest — inicializar app, limpiar BD, seeds

```typescript
// test/users.e2e-spec.ts
import { Test, TestingModule } from '@nestjs/testing';
import { INestApplication, ValidationPipe } from '@nestjs/common';
import * as request from 'supertest';
import { AppModule } from '../src/app.module';
import { PrismaService } from '../src/prisma/prisma.service';

describe('UsersController (e2e)', () => {
  let app: INestApplication;
  let prisma: PrismaService;

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

    app = moduleFixture.createNestApplication();

    // Replicar la configuración de main.ts
    app.useGlobalPipes(
      new ValidationPipe({
        whitelist: true,
        forbidNonWhitelisted: true,
        transform: true,
      }),
    );

    await app.init();
    prisma = app.get<PrismaService>(PrismaService);
  });

  beforeEach(async () => {
    // Limpiar en orden correcto para respetar claves foráneas
    await prisma.session.deleteMany();
    await prisma.user.deleteMany();
  });

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

  describe('POST /usuarios', () => {
    it('crea un usuario con datos válidos (201)', async () => {
      const res = await request(app.getHttpServer())
        .post('/usuarios')
        .send({ email: 'nuevo@ejemplo.com', password: 'segura1234', nombre: 'Test' })
        .expect(201);

      expect(res.body).toMatchObject({
        email: 'nuevo@ejemplo.com',
        nombre: 'Test',
      });
      expect(res.body).not.toHaveProperty('password');
      expect(res.body).toHaveProperty('id');
    });

    it('retorna 422 con email inválido', async () => {
      const res = await request(app.getHttpServer())
        .post('/usuarios')
        .send({ email: 'no-es-email', password: 'segura1234' })
        .expect(422);

      expect(res.body.message).toBe('Error de validación');
    });

    it('retorna 409 si el correo ya existe', async () => {
      const dto = { email: 'duplicado@ejemplo.com', password: 'segura1234' };
      await request(app.getHttpServer()).post('/usuarios').send(dto).expect(201);
      await request(app.getHttpServer()).post('/usuarios').send(dto).expect(409);
    });
  });
});
```

---

## Fixtures y helpers reutilizables

```typescript
// test/helpers/crear-usuario.helper.ts
import type { PrismaService } from '../../src/prisma/prisma.service';
import * as bcrypt from 'bcrypt';

export interface UsuarioFixture {
  id: string;
  email: string;
  password: string;         // contraseña en texto plano para tests
  passwordHash: string;
}

export async function crearUsuarioFixture(
  prisma: PrismaService,
  overrides: Partial<{ email: string; password: string; rol: string }> = {},
): Promise<UsuarioFixture> {
  const password = overrides.password ?? 'segura1234';
  const passwordHash = await bcrypt.hash(password, 10);

  const usuario = await prisma.user.create({
    data: {
      email: overrides.email ?? `test-${Date.now()}@ejemplo.com`,
      password: passwordHash,
      rol: overrides.rol ?? 'LECTOR',
    },
  });

  return { id: usuario.id, email: usuario.email, password, passwordHash };
}

// test/helpers/obtener-token.helper.ts
import type { INestApplication } from '@nestjs/common';
import * as request from 'supertest';

export async function obtenerTokenJwt(
  app: INestApplication,
  email: string,
  password: string,
): Promise<string> {
  const res = await request(app.getHttpServer())
    .post('/auth/login')
    .send({ email, password })
    .expect(200);

  return res.body.accessToken as string;
}
```

---

## Coverage con Jest — configuración recomendada

```json
// jest.config.ts (o campo "jest" en package.json)
{
  "moduleFileExtensions": ["js", "json", "ts"],
  "rootDir": "src",
  "testRegex": ".*\\.spec\\.ts$",
  "transform": { "^.+\\.(t|j)s$": "ts-jest" },
  "collectCoverageFrom": [
    "**/*.(t|j)s",
    "!**/*.dto.ts",
    "!**/*.entity.ts",
    "!**/*.module.ts",
    "!**/main.ts",
    "!**/*.mock.ts"
  ],
  "coverageDirectory": "../coverage",
  "coverageThresholds": {
    "global": {
      "branches": 80,
      "functions": 85,
      "lines": 85,
      "statements": 85
    }
  },
  "testEnvironment": "node"
}
```

Los DTOs y entidades se excluyen del coverage porque son declarativos.
Los umbrales del 80-85% son un punto de partida; ajustar según criticidad del módulo.
