🚨 ¡Nueva review! ¡Mi teclado ideal! ⌨️ Perfecto para programar, el Logitech MX Keys S . ¡Échale un ojo! 👀

Testing unitario con Jest en NestJS

Serie NestJS #20 : @nestjs/testing, Test.createTestingModule, mocking servicios y repositorios, jest.fn(), jest.spyOn, testear controllers, services, guards y pipes

Escrito por domin el 18 de abril de 2026

Vamos con el post número 20 de la serie NestJS y primero del bloque de Testing. En los 19 posts hemos construido una API con controllers (post 2), services con DI (post 3), módulos (post 4), middleware (post 5), validación (post 6), TypeORM (posts 7-9), auth con JWT (post 10), guards con RBAC (post 11), Exception Filters (post 13), interceptors (post 14), serialización (post 15), file uploads (post 16), eventos (post 17), colas (post 18) y WebSockets (post 19).

Mucho código y ningún test, una locura!

Un código sin tests es código que funciona de potra. El día que refactorizas, añades una feature o actualizas una dependencia, no tienes forma de saber si has roto algo. Lo descubres de sorpresa cuando ya está en producción, con una incidencia para ayer y mucho estrés. Los tests son la garantía de seguridad que te permite cambiar código con confianza de que no te vas a petar nada.

EA, amo al lío.

Diagrama de tests unitarios aislados en NestJS con Jest verificando cada pieza del pipeline de forma independiente.

1. ¿Qué es un test unitario?

Un prueba una sola pieza de forma aislada:

Test unitario del UsersService:
 Prueba: "findOne devuelve un usuario si existe"
 Prueba: "findOne lanza NotFoundException si no existe"
 La base de datos es un mock no necesita PostgreSQL real
 Se ejecuta en milisegundos
 Si falla, sabes EXACTAMENTE qué se rompió

Test E2E (post 21):
 Prueba: "POST /auth/register → 201 + tokens válidos"
 Usa la base de datos real necesita Docker
 Se ejecuta en segundos
 Si falla, puede ser el controller, el service, la BD, la validación...
🔬 Test unitario (este post)

Prueba una pieza aislada. Dependencias mockeadas. Tardán milisegundos. Sabes con precisión qué se rompió. El 80% de tus tests.

🔭 Test E2E (siguiente post)

Prueba el flujo completo. BD real, HTTP real. Tardán segundos. Detecta problemas de integración. El 20% de tus tests.


2. Jest en NestJS: lo que ya tienes

NestJS viene con Jest preconfigurado. Si creaste el proyecto con nest new, ya tienes todo:

Verificar que Jest funciona 0 / 2
$
Pulsa para ejecutar el siguiente comando

La configuración por defecto en package.json:

{
    "jest": {
        "moduleFileExtensions": ["js", "json", "ts"],
        "rootDir": "src",
        "testRegex": ".*\\.spec\\.ts$",
        "transform": {
            "^.+\\.(t|j)s$": "ts-jest"
        },
        "collectCoverageFrom": ["**/*.(t|j)s"],
        "coverageDirectory": "../coverage",
        "testEnvironment": "node"
    }
}

Los tests viven al lado de lo que testean: users.service.tsusers.service.spec.ts. El sufijo .spec.ts es lo que Jest busca.


3. La anatomía de un test en NestJS

El pattern básico: Arrange, Act, Assert.

// src/users/users.service.spec.ts
import { Test, type TestingModule } from '@nestjs/testing';
import { UsersService } from './users.service';
import { getRepositoryToken } from '@nestjs/typeorm';
import { User } from './entities/user.entity';

describe('UsersService', () => {
    let service: UsersService;
    // ARRANGE: montar el módulo de test
    beforeEach(async () => {
        const module: TestingModule = await Test.createTestingModule({
            providers: [
                UsersService,
                {
                    provide: getRepositoryToken(User),
                    useValue: {
                        findOne: jest.fn(),
                        save: jest.fn(),
                        create: jest.fn(),
                    },
                },
            ],
        }).compile();
        service = module.get<UsersService>(UsersService);
    });
    it('should be defined', () => {
        // ASSERT
        expect(service).toBeDefined();
    });
});

Desglose del código del test:


4. Mocking: el corazón del test unitario

En un test unitario, todo lo que no es la unidad bajo test se mockea. El reemplaza la dependencia real con una versión controlada:

// La dependencia real haría esto:
const user = await this.usersRepository.findOne({ where: { id } });
// → Consulta a PostgreSQL → red → disco → resultado
// y el mock hace esto:
const mockFindOne = jest.fn().mockResolvedValue({
    id: 'uuid-1',
    email: 'juan@test.com',
    name: 'Juan',
});
// → Devuelve el objeto directamente. Sin red ni disco y en microsegundos.

4.1. jest.fn(): la función espía

// Crear un mock vacío (devuelve undefined)
const myFn = jest.fn();
// Mock que devuelve un valor
const myFn = jest.fn().mockReturnValue(42);
// Mock que devuelve una Promise resuelta
const myFn = jest.fn().mockResolvedValue({ id: '1', name: 'Juan' });
// Mock que devuelve una Promise rechazada
const myFn = jest.fn().mockRejectedValue(new Error('DB connection failed'));
// Mock con implementación custom
const myFn = jest.fn().mockImplementation((id: string) => ({
    id,
    name: `User ${id}`,
}));

4.2. jest.fn() tipado, sin any

// ❌ jest.fn() sin tipo: el mock acepta cualquier cosa
const findOne = jest.fn();
// ✅ jest.fn() tipado: TypeScript verifica los argumentos y el retorno
const findOne = jest.fn<Promise<User | null>, [{ where: { id: string } }]>();

Y mejor aún sería crear un tipo helper para los mocks de repositorios:

// src/common/testing/mock-repository.type.ts
import { type Repository } from 'typeorm';

export type MockRepository<T> = Partial<Record<keyof Repository<T>, jest.Mock>>;

Ahora todos tus mocks de repositorio tienen el tipo correcto:

let usersRepository: MockRepository<User>;

beforeEach(async () => {
    const module: TestingModule = await Test.createTestingModule({
        providers: [
            UsersService,
            {
                provide: getRepositoryToken(User),
                useValue: {
                    findOne: jest.fn(),
                    save: jest.fn(),
                    create: jest.fn(),
                    exists: jest.fn(),
                    softRemove: jest.fn(),
                },
            },
        ],
    }).compile();

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

5. Testeando un Service: el caso completo

Vamos a testear el UsersService que construimos en posts anteriores:

// src/users/users.service.spec.ts
import { Test, type TestingModule } from '@nestjs/testing';
import { NotFoundException } from '@nestjs/common';
import { getRepositoryToken } from '@nestjs/typeorm';
import { UsersService } from './users.service';
import { User } from './entities/user.entity';
import { type MockRepository } from '../common/testing/mock-repository.type';

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

    // Datos de prueba reutilizables
    const mockUser: User = {
        id: 'uuid-1',
        email: 'juan@test.com',
        name: 'Juan García',
        password: '$2b$10$hashedpassword',
        roles: ['user'],
        createdAt: new Date('2026-01-01'),
        updatedAt: new Date('2026-01-01'),
        deletedAt: null,
        posts: [],
    } as User;

    beforeEach(async () => {
        const module: TestingModule = await Test.createTestingModule({
            providers: [
                UsersService,
                {
                    provide: getRepositoryToken(User),
                    useValue: {
                        findOne: jest.fn(),
                        find: jest.fn(),
                        save: jest.fn(),
                        create: jest.fn(),
                        merge: jest.fn(),
                        exists: jest.fn(),
                        softRemove: jest.fn(),
                    },
                },
            ],
        }).compile();

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

    // Verificar que la inyección funciona
    it('should be defined', () => {
        expect(service).toBeDefined();
    });

    describe('findOne', () => {
        it('should return a user when found', async () => {
            // ARRANGE: el repository devuelve un usuario
            usersRepository.findOne!.mockResolvedValue(mockUser);

            // ACT
            const result = await service.findOne('uuid-1');

            // ASSERT
            expect(result).toEqual(mockUser);
            expect(usersRepository.findOne).toHaveBeenCalledWith({
                where: { id: 'uuid-1' },
            });
            expect(usersRepository.findOne).toHaveBeenCalledTimes(1);
        });

        it('should throw NotFoundException when user not found', async () => {
            // ARRANGE: el repository devuelve null
            usersRepository.findOne!.mockResolvedValue(null);

            // ACT & ASSERT
            await expect(service.findOne('uuid-999')).rejects.toThrow(NotFoundException);
            await expect(service.findOne('uuid-999')).rejects.toThrow('Usuario con ID "uuid-999" no encontrado');
        });
    });

    describe('create', () => {
        const createUserDto = {
            email: 'nuevo@test.com',
            name: 'Nuevo Usuario',
            password: 'Password123!',
        };

        it('should create and return a new user', async () => {
            const createdUser = { ...mockUser, ...createUserDto, id: 'uuid-2' };

            usersRepository.exists!.mockResolvedValue(false);
            usersRepository.create!.mockReturnValue(createdUser);
            usersRepository.save!.mockResolvedValue(createdUser);

            const result = await service.create(createUserDto);

            expect(result).toEqual(createdUser);
            expect(usersRepository.exists).toHaveBeenCalledWith({
                where: { email: createUserDto.email },
            });
            expect(usersRepository.create).toHaveBeenCalledWith(
                expect.objectContaining({
                    email: createUserDto.email,
                    name: createUserDto.name,
                })
            );
            expect(usersRepository.save).toHaveBeenCalledTimes(1);
        });

        it('should throw ConflictException if email already exists', async () => {
            usersRepository.exists!.mockResolvedValue(true);

            await expect(service.create(createUserDto)).rejects.toThrow('El email ya está registrado');
            expect(usersRepository.save).not.toHaveBeenCalled();
        });
    });

    describe('update', () => {
        const updateUserDto = { name: 'Juan Actualizado' };

        it('should update and return the user', async () => {
            const updatedUser = { ...mockUser, ...updateUserDto };

            usersRepository.findOne!.mockResolvedValue(mockUser);
            usersRepository.merge!.mockReturnValue(updatedUser);
            usersRepository.save!.mockResolvedValue(updatedUser);

            const result = await service.update('uuid-1', updateUserDto, 'admin-id');

            expect(result.name).toBe('Juan Actualizado');
            expect(usersRepository.merge).toHaveBeenCalledWith(mockUser, updateUserDto);
        });

        it('should throw NotFoundException if user does not exist', async () => {
            usersRepository.findOne!.mockResolvedValue(null);

            await expect(service.update('uuid-999', updateUserDto, 'admin-id')).rejects.toThrow(NotFoundException);
        });
    });

    describe('remove', () => {
        it('should soft-remove the user', async () => {
            usersRepository.findOne!.mockResolvedValue(mockUser);
            usersRepository.softRemove!.mockResolvedValue(mockUser);

            await service.remove('uuid-1', 'admin-id');

            expect(usersRepository.softRemove).toHaveBeenCalledWith(mockUser);
        });

        it('should throw NotFoundException if user does not exist', async () => {
            usersRepository.findOne!.mockResolvedValue(null);

            await expect(service.remove('uuid-999', 'admin-id')).rejects.toThrow(NotFoundException);
            expect(usersRepository.softRemove).not.toHaveBeenCalled();
        });
    });
});

Fíjate en los patrones:

  1. describe anidados : Agrupa tests por método. describe('findOne') contiene todos los tests de findOne.
  2. Happy path primero : 'should return a user when found' antes que el caso de error.
  3. expect().rejects.toThrow() : Para verificar que una Promise rechaza con una excepción específica.
  4. expect().not.toHaveBeenCalled() : Verifica que el mock NO fue llamado. Importante si el email ya existe, save no debería ejecutarse.
  5. expect.objectContaining() : Verifica un subset de las propiedades. No necesitas matchear el objeto completo si solo te interesa verificar ciertos campos.

6. Testeando un Controller

Los controllers son sencillos porque llaman al service y devuelven la respuesta. El test verifica que el controller llama al service correcto con los argumentos correctos:

// src/users/users.controller.spec.ts
import { Test, type TestingModule } from '@nestjs/testing';
import { UsersController } from './users.controller';
import { UsersService } from './users.service';
import { type User } from './entities/user.entity';

describe('UsersController', () => {
    let controller: UsersController;
    let usersService: jest.Mocked<Pick<UsersService, 'findOne' | 'findAll' | 'create' | 'update' | 'remove'>>;

    const mockUser = {
        id: 'uuid-1',
        email: 'juan@test.com',
        name: 'Juan García',
        roles: ['user'],
    } as User;

    beforeEach(async () => {
        const module: TestingModule = await Test.createTestingModule({
            controllers: [UsersController],
            providers: [
                {
                    provide: UsersService,
                    useValue: {
                        findOne: jest.fn(),
                        findAll: jest.fn(),
                        create: jest.fn(),
                        update: jest.fn(),
                        remove: jest.fn(),
                    },
                },
            ],
        }).compile();

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

    describe('findOne', () => {
        it('should return a user by id', async () => {
            usersService.findOne.mockResolvedValue(mockUser);

            const result = await controller.findOne('uuid-1');

            expect(result).toEqual(mockUser);
            expect(usersService.findOne).toHaveBeenCalledWith('uuid-1');
        });
    });

    describe('create', () => {
        it('should create and return a new user', async () => {
            const createDto = {
                email: 'nuevo@test.com',
                name: 'Nuevo',
                password: 'Pass123!',
            };
            const created = { ...mockUser, ...createDto, id: 'uuid-2' } as User;

            usersService.create.mockResolvedValue(created);

            const result = await controller.create(createDto);

            expect(result.email).toBe('nuevo@test.com');
            expect(usersService.create).toHaveBeenCalledWith(createDto);
        });
    });

    describe('update', () => {
        it('should update and return the user', async () => {
            const updateDto = { name: 'Actualizado' };
            const updated = { ...mockUser, name: 'Actualizado' } as User;

            usersService.update.mockResolvedValue(updated);

            const result = await controller.update(
                'uuid-1',
                updateDto,
                { user: { id: 'admin-id' } } as any // request mock simplificado
            );

            expect(result.name).toBe('Actualizado');
        });
    });

    describe('remove', () => {
        it('should call service.remove with correct id', async () => {
            usersService.remove.mockResolvedValue(undefined);

            await controller.remove('uuid-1', { user: { id: 'admin-id' } } as any);

            expect(usersService.remove).toHaveBeenCalledWith('uuid-1', 'admin-id');
        });
    });
});

La diferencia en el controller test, es que mockeamos el service completo, no el repositorio. El controller no sabe nada de TypeORM, solo llama al service.

¿Es necesario testear controllers? Depende. Si tu controller solo hace return this.service.method(), el test es casi trivial. Pero si tiene lógica (transformar datos, combinar resultados de varios services), el test tiene valor real.


7. jest.spyOn: espiar sin reemplazar

jest.fn() reemplaza completamente la función. jest.spyOn() espía una función real sin cambiar su implementación (a menos que quieras):

// src/auth/auth.service.spec.ts
import { Test, type TestingModule } from '@nestjs/testing';
import { JwtService } from '@nestjs/jwt';
import { AuthService } from './auth.service';

describe('AuthService', () => {
    let authService: AuthService;
    let jwtService: JwtService;

    beforeEach(async () => {
        const module: TestingModule = await Test.createTestingModule({
            providers: [
                AuthService,
                {
                    provide: JwtService,
                    useValue: {
                        signAsync: jest.fn(),
                        verify: jest.fn(),
                    },
                },
                // ... otros providers mockeados
            ],
        }).compile();

        authService = module.get<AuthService>(AuthService);
        jwtService = module.get<JwtService>(JwtService);
    });

    describe('generateTokens', () => {
        it('should call jwtService.signAsync twice (access + refresh)', async () => {
            // Espiar signAsync y controlar lo que devuelve
            const signSpy = jest
                .spyOn(jwtService, 'signAsync')
                .mockResolvedValueOnce('access-token-123')
                .mockResolvedValueOnce('refresh-token-456');

            const tokens = await authService.generateTokens({
                id: 'uuid-1',
                email: 'juan@test.com',
            });

            expect(tokens.accessToken).toBe('access-token-123');
            expect(tokens.refreshToken).toBe('refresh-token-456');
            expect(signSpy).toHaveBeenCalledTimes(2);

            // Verificar que el primer sign fue el access token
            expect(signSpy).toHaveBeenNthCalledWith(
                1,
                expect.objectContaining({ sub: 'uuid-1' }),
                expect.objectContaining({ expiresIn: '15m' })
            );

            // El segundo fue el refresh token
            expect(signSpy).toHaveBeenNthCalledWith(
                2,
                expect.objectContaining({ sub: 'uuid-1' }),
                expect.objectContaining({ expiresIn: '7d' })
            );
        });
    });
});

Diferencias entre jest.fn() y jest.spyOn():

Característicajest.fn()jest.spyOn()
Crea función nuevaNo, espía la existente
Mantiene implementación originalNo

(por defecto)

Puede sobreescribir

(mockReturnValue, etc.)

(mockReturnValue, etc.)

Restaurar originalNo aplicaspy.mockRestore()
Caso de usoMocks de dependencias inyectadasEspiar métodos internos, verificar llamadas

mockResolvedValueOnce es la clave porque devuelve un valor específico solo la primera vez que se llama. La segunda llamada devuelve otro valor. Perfecto para funciones que se llaman varias veces con resultados diferentes (como generar access + refresh tokens).


8. Testeando un Guard

Los guards del post 11 se testean simulando el ExecutionContext:

// src/auth/guards/roles.guard.spec.ts
import { type ExecutionContext } from '@nestjs/common';
import { Reflector } from '@nestjs/core';
import { RolesGuard } from './roles.guard';

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

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

    const createMockContext = (userRoles: string[], requiredRoles: string[] | undefined): ExecutionContext => {
        // Mock del Reflector: devuelve los roles requeridos
        jest.spyOn(reflector, 'getAllAndOverride').mockReturnValue(requiredRoles);

        return {
            switchToHttp: () => ({
                getRequest: () => ({
                    user: { id: 'uuid-1', roles: userRoles },
                }),
            }),
            getHandler: () => jest.fn(),
            getClass: () => jest.fn(),
        } as unknown as ExecutionContext;
    };

    it('should allow access when no roles are required', () => {
        const context = createMockContext(['user'], undefined);
        expect(guard.canActivate(context)).toBe(true);
    });

    it('should allow access when user has required role', () => {
        const context = createMockContext(['admin'], ['admin']);
        expect(guard.canActivate(context)).toBe(true);
    });

    it('should allow access when user has one of the required roles', () => {
        const context = createMockContext(['editor'], ['admin', 'editor']);
        expect(guard.canActivate(context)).toBe(true);
    });

    it('should deny access when user lacks required role', () => {
        const context = createMockContext(['user'], ['admin']);
        expect(guard.canActivate(context)).toBe(false);
    });

    it('should deny access when user has no roles', () => {
        const context = createMockContext([], ['admin']);
        expect(guard.canActivate(context)).toBe(false);
    });
});

El ExecutionContext es la parte más percal de mockear. La función helper createMockContext() centraliza la creación del mock. Cada test solo necesita decir qué roles tiene el usuario y qué roles requiere la ruta.

Haz funciones helper para mocks complejos. Si vas a crear el mismo mock en varios tests, extráelo a una función. Reduce duplicación y hace los tests más legibles.


9. Testeando un Pipe

Los pipes del post 6 son los más fáciles de testear porque son funciones puras porque reciben un valor, devuelven otro o lanzan una excepción:

// src/common/pipes/parse-uuid.pipe.spec.ts
import { BadRequestException } from '@nestjs/common';
import { ParseUUIDPipe } from './parse-uuid.pipe';

describe('ParseUUIDPipe', () => {
    let pipe: ParseUUIDPipe;

    beforeEach(() => {
        pipe = new ParseUUIDPipe();
    });

    it('should pass through a valid UUID v4', () => {
        const validUuid = '550e8400-e29b-41d4-a716-446655440000';
        expect(pipe.transform(validUuid)).toBe(validUuid);
    });

    it('should throw BadRequestException for invalid UUID', () => {
        expect(() => pipe.transform('not-a-uuid')).toThrow(BadRequestException);
    });

    it('should throw BadRequestException for empty string', () => {
        expect(() => pipe.transform('')).toThrow(BadRequestException);
    });

    it('should throw BadRequestException for numeric string', () => {
        expect(() => pipe.transform('12345')).toThrow(BadRequestException);
    });
});

Sin mocks y sin Test.createTestingModule, solo new Pipe() y pipe.transform().


10. Testeando un Interceptor

Los interceptors del post 14 son más interesantes porque trabajan con Observables de RxJS:

// src/common/interceptors/transform-response.interceptor.spec.ts
import { type CallHandler, type ExecutionContext } from '@nestjs/common';
import { of } from 'rxjs';
import { TransformResponseInterceptor } from './transform-response.interceptor';

interface StandardResponse<T> {
    data: T;
    statusCode: number;
    timestamp: string;
}

describe('TransformResponseInterceptor', () => {
    let interceptor: TransformResponseInterceptor<unknown>;

    beforeEach(() => {
        interceptor = new TransformResponseInterceptor();
    });

    const createMockContext = (statusCode = 200): ExecutionContext =>
        ({
            switchToHttp: () => ({
                getResponse: () => ({ statusCode }),
            }),
        }) as unknown as ExecutionContext;

    const createMockCallHandler = (data: unknown): CallHandler => ({
        handle: () => of(data),
    });

    it('should wrap response in standard format', (done) => {
        const context = createMockContext(200);
        const handler = createMockCallHandler({ id: '1', name: 'Juan' });

        interceptor.intercept(context, handler).subscribe({
            next: (result: StandardResponse<unknown>) => {
                expect(result).toHaveProperty('data');
                expect(result).toHaveProperty('statusCode', 200);
                expect(result).toHaveProperty('timestamp');
                expect(result.data).toEqual({ id: '1', name: 'Juan' });
            },
            complete: () => done(),
        });
    });

    it('should handle null response', (done) => {
        const context = createMockContext(204);
        const handler = createMockCallHandler(null);

        interceptor.intercept(context, handler).subscribe({
            next: (result: StandardResponse<unknown>) => {
                expect(result.data).toBeNull();
                expect(result.statusCode).toBe(204);
            },
            complete: () => done(),
        });
    });

    it('should handle array response', (done) => {
        const context = createMockContext(200);
        const handler = createMockCallHandler([{ id: '1' }, { id: '2' }]);

        interceptor.intercept(context, handler).subscribe({
            next: (result: StandardResponse<unknown>) => {
                expect(Array.isArray(result.data)).toBe(true);
            },
            complete: () => done(),
        });
    });
});

Puntos clave del asunto:


11. Testeando un Exception Filter

Testeamos los Exception Filters del post 13:

// src/common/filters/http-exception.filter.spec.ts
import { HttpException, HttpStatus, type ArgumentsHost } from '@nestjs/common';
import { HttpExceptionFilter } from './http-exception.filter';

describe('HttpExceptionFilter', () => {
    let filter: HttpExceptionFilter;

    beforeEach(() => {
        filter = new HttpExceptionFilter();
    });

    const createMockHost = (): {
        host: ArgumentsHost;
        mockJson: jest.Mock;
        mockStatus: jest.Mock;
    } => {
        const mockJson = jest.fn();
        const mockStatus = jest.fn().mockReturnValue({ json: mockJson });

        const host = {
            switchToHttp: () => ({
                getResponse: () => ({
                    status: mockStatus,
                }),
                getRequest: () => ({
                    url: '/test/endpoint',
                    method: 'GET',
                }),
            }),
        } as unknown as ArgumentsHost;

        return { host, mockJson, mockStatus };
    };

    it('should format the error response correctly', () => {
        const { host, mockJson, mockStatus } = createMockHost();
        const exception = new HttpException('Not found', HttpStatus.NOT_FOUND);

        filter.catch(exception, host);

        expect(mockStatus).toHaveBeenCalledWith(404);
        expect(mockJson).toHaveBeenCalledWith(
            expect.objectContaining({
                statusCode: 404,
                message: 'Not found',
                path: '/test/endpoint',
            })
        );
    });

    it('should handle exception with object response', () => {
        const { host, mockJson, mockStatus } = createMockHost();
        const exception = new HttpException(
            { message: 'Validation failed', errors: ['email is required'] },
            HttpStatus.BAD_REQUEST
        );

        filter.catch(exception, host);

        expect(mockStatus).toHaveBeenCalledWith(400);
        expect(mockJson).toHaveBeenCalledWith(
            expect.objectContaining({
                statusCode: 400,
            })
        );
    });
});

El mock del ArgumentsHost simula la cadena host.switchToHttp().getResponse().status(code).json(body). Los jest.fn() espían cada paso.


12. Testeando un service con EventEmitter

Los eventos del post 17 también se testean verificando que el service emite el evento correcto:

// src/auth/auth.service.spec.ts (parcial : parte de eventos)
import { EventEmitter2 } from '@nestjs/event-emitter';
import { EventNames } from '../common/events/event-names';
import { UserRegisteredEvent } from './events/user-registered.event';

describe('AuthService', () => {
    let authService: AuthService;
    let eventEmitter: jest.Mocked<Pick<EventEmitter2, 'emit'>>;

    beforeEach(async () => {
        const module: TestingModule = await Test.createTestingModule({
            providers: [
                AuthService,
                {
                    provide: EventEmitter2,
                    useValue: {
                        emit: jest.fn(),
                    },
                },
                // ... otros providers
            ],
        }).compile();

        authService = module.get<AuthService>(AuthService);
        eventEmitter = module.get(EventEmitter2);
    });

    describe('register', () => {
        it('should emit UserRegisteredEvent after successful registration', async () => {
            // ... setup mocks de repository, jwt, bcrypt

            await authService.register({
                email: 'nuevo@test.com',
                name: 'Nuevo',
                password: 'Pass123!',
            });

            expect(eventEmitter.emit).toHaveBeenCalledWith(EventNames.USER_REGISTERED, expect.any(UserRegisteredEvent));

            // Verificar los datos del evento
            const emittedEvent = eventEmitter.emit.mock.calls[0][1] as UserRegisteredEvent;
            expect(emittedEvent.email).toBe('nuevo@test.com');
            expect(emittedEvent.name).toBe('Nuevo');
        });

        it('should NOT emit event if registration fails', async () => {
            // Mock: email duplicado
            usersRepository.exists!.mockResolvedValue(true);

            await expect(
                authService.register({
                    email: 'existente@test.com',
                    name: 'Ya existe',
                    password: 'Pass123!',
                })
            ).rejects.toThrow();

            expect(eventEmitter.emit).not.toHaveBeenCalled();
        });
    });
});

eventEmitter.emit.mock.calls[0][1] accede a los argumentos de la primera llamada al mock. calls[0] es la primera llamada, [1] es el segundo argumento (el payload del evento). Así puedes verificar los datos exactos del evento emitido.


13. Factory de mocks: escalar sin duplicar

Cuando tienes 20 specs que necesitan el mismo mock del repositorio, extrae los mocks a factories:

// src/common/testing/mock-repository.factory.ts
import { type Repository } from 'typeorm';

type MockRepository<T> = Partial<Record<keyof Repository<T>, jest.Mock>>;

export function createMockRepository<T>(): MockRepository<T> {
    return {
        findOne: jest.fn(),
        find: jest.fn(),
        save: jest.fn(),
        create: jest.fn(),
        merge: jest.fn(),
        exists: jest.fn(),
        softRemove: jest.fn(),
        remove: jest.fn(),
        count: jest.fn(),
        createQueryBuilder: jest.fn(),
    };
}
// src/common/testing/mock-event-emitter.factory.ts
export function createMockEventEmitter() {
    return {
        emit: jest.fn(),
        emitAsync: jest.fn(),
    };
}

Y así lo usamos:

import { createMockRepository } from '../common/testing/mock-repository.factory';
import { createMockEventEmitter } from '../common/testing/mock-event-emitter.factory';

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

Así nos ahorramos unas pocas de líneas. Y si necesitas añadir un método al mock del repositorio, lo cambias en un solo sitio e ya.


14. Ejecutar los tests

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

Los comandos:


15. beforeEach vs beforeAll

// beforeEach: se ejecuta ANTES de CADA test
// Los mocks se resetean entre tests → tests independientes
beforeEach(async () => {
    const module = await Test.createTestingModule({...}).compile();
    service = module.get(UsersService);
});

// beforeAll: se ejecuta UNA VEZ antes de todos los tests del describe
// El módulo se reutiliza → más rápido, pero los tests comparten estado
beforeAll(async () => {
    const module = await Test.createTestingModule({...}).compile();
    service = module.get(UsersService);
});
Usa beforeEach (recomendado)
  • Tests completamente aislados: un mock modificado en un test no afecta a los demás
  • Puedes modificar mocks libremente con mockResolvedValue sin efectos secundarios
  • Si un test falla, es por su propia lógica, no por estado residual de otro test
Usa beforeAll (con cuidado)
  • Tests que SOLO leen datos (nunca modifican mocks). Ej: testear constantes o funciones puras
  • El módulo es muy pesado de compilar y necesitas optimizar velocidad
  • Si usas beforeAll con mocks, DEBES llamar jest.clearAllMocks() en beforeEach para resetear los contadores

16. Matchers de Jest: la referencia

Los matchers más usados en tests de NestJS:

MatcherQué verificaEjemplo
toBe()Igualdad estricta (===)expect(1 + 1).toBe(2)
toEqual()Igualdad profunda de objetosexpect(user).toEqual({ id: ‘1’ })
toBeDefined()No es undefinedexpect(service).toBeDefined()
toBeNull()Es nullexpect(result).toBeNull()
toThrow()Lanza una excepciónexpect(() => fn()).toThrow(Error)
rejects.toThrow()Promise rechaza con excepciónawait expect(fn()).rejects.toThrow()
toHaveBeenCalledWith()Mock llamado con argumentosexpect(mock).toHaveBeenCalledWith(‘id’)
toHaveBeenCalledTimes()Mock llamado N vecesexpect(mock).toHaveBeenCalledTimes(1)
not.toHaveBeenCalled()Mock NO fue llamadoexpect(save).not.toHaveBeenCalled()
expect.objectContaining()Subset de propiedadesexpect(obj).toEqual(expect.objectContaining({ id: ‘1’ }))

17. Estructura de archivos de tests

src/
├── users/
   ├── users.controller.ts
   ├── users.controller.spec.ts Test del controller
   ├── users.service.ts
   ├── users.service.spec.ts Test del service
   ├── dto/
   ├── create-user.dto.ts
   └── create-user.dto.spec.ts Test del DTO (validación)
   └── entities/
       └── user.entity.ts
├── auth/
   ├── auth.service.ts
   ├── auth.service.spec.ts
   └── guards/
       ├── roles.guard.ts
       └── roles.guard.spec.ts Test del guard
├── common/
   ├── pipes/
   ├── parse-uuid.pipe.ts
   └── parse-uuid.pipe.spec.ts Test del pipe
   ├── interceptors/
   ├── transform-response.interceptor.ts
   └── transform-response.interceptor.spec.ts
   ├── filters/
   ├── http-exception.filter.ts
   └── http-exception.filter.spec.ts
   └── testing/
       ├── mock-repository.type.ts
       ├── mock-repository.factory.ts
       └── mock-event-emitter.factory.ts

Cada .ts tiene su .spec.ts al lado. La carpeta testing/ centraliza los helpers reutilizables.


18. Errores comunes

Error 1: No resetear mocks entre tests

// ❌ Sin beforeEach, los mocks acumulan llamadas entre tests
describe('UsersService', () => {
    const module = Test.createTestingModule({...}).compile();
    // Si test A llama findOne, test B ve que findOne fue llamada 2 veces

    it('test A', () => { service.findOne('1'); });
    it('test B', () => {
        service.findOne('2');
        // ❌ toHaveBeenCalledTimes(1) falla porque acumula la llamada del test A
        expect(usersRepository.findOne).toHaveBeenCalledTimes(1);
    });
});

// ✅ beforeEach recrea el módulo y los mocks para cada test
beforeEach(async () => {
    const module = await Test.createTestingModule({...}).compile();
    // Cada test empieza con mocks limpios
});

Error 2: Testear la implementación, no el comportamiento

// ❌ Test frágil: verifica CÓMO el service hace las cosas
it('should hash password with bcrypt using 10 rounds', async () => {
    await service.create(dto);
    expect(bcrypt.hash).toHaveBeenCalledWith('Pass123!', 10);
    // Si cambias de bcrypt a argon2, este test falla aunque el comportamiento sea correcto
});

// ✅ Test robusto: verifica QUÉ hace el service
it('should not store the password in plain text', async () => {
    usersRepository.create!.mockImplementation((data) => data as User);
    usersRepository.save!.mockImplementation((user) => Promise.resolve(user as User));

    await service.create({ email: 'a@b.com', name: 'Test', password: 'Pass123!' });

    const savedUser = usersRepository.save!.mock.calls[0][0] as User;
    expect(savedUser.password).not.toBe('Pass123!');
    // No importa si usa bcrypt, argon2 o scrypt. Lo que importa es que NO guarda el plain text
});

Error 3: Olvidar el await en tests async

// ❌ Sin await: el test pasa aunque la Promise rechace
it('should throw', () => {
    expect(service.findOne('bad')).rejects.toThrow(); // Sin await → silently ignored
});

// ✅ Con await: Jest espera a que la Promise se resuelva/rechace
it('should throw', async () => {
    await expect(service.findOne('bad')).rejects.toThrow(NotFoundException);
});

Error 4: No tipar los mocks

// ❌ Mock sin tipar: cualquier cosa pasa, errores silenciosos
const mockService = {
    findOne: jest.fn(),
    fimdAll: jest.fn(), // Typo: 'fimdAll' en vez de 'findAll'. No da error
};

// ✅ Mock tipado: TypeScript detecta el typo
const mockService: jest.Mocked<Pick<UsersService, 'findOne' | 'findAll'>> = {
    findOne: jest.fn(),
    fimdAll: jest.fn(), // ❌ TypeScript error: 'fimdAll' does not exist
};

Error 5: Tests con dependencias entre sí

// ❌ Test B depende de que Test A se haya ejecutado antes
it('should create a user', async () => {
    createdUserId = (await service.create(dto)).id; // variable global
});

it('should find the created user', async () => {
    const user = await service.findOne(createdUserId); // Depende del test anterior
    // Si test A falla o se ejecuta en otro orden, test B también falla
});

// ✅ Cada test es independiente
it('should find a user', async () => {
    usersRepository.findOne!.mockResolvedValue(mockUser);
    const user = await service.findOne('uuid-1');
    expect(user).toEqual(mockUser);
});

19. Recapitulando

🧪 Test.createTestingModule

Crea un módulo de NestJS aislado para tests. Inyecta mocks en vez de dependencias reales. El pilar del testing en NestJS.

🕵️ jest.fn() + jest.spyOn()

jest.fn() crea funciones mock desde cero. jest.spyOn() espía funciones existentes. Ambos registran llamadas, argumentos y valores de retorno.

🏷️ Mocks tipados

MockRepository, jest.Mocked>. TypeScript verifica que los mocks coincidan con la interfaz real. Cero any.

⚙️ Services

Mock del repository con getRepositoryToken(). Testea caso feliz y caso de error. Verifica llamadas con toHaveBeenCalledWith().

🛡️ Guards + Pipes + Filters

Mock de ExecutionContext y ArgumentsHost con helper functions. Pipes son los más fáciles: entrada → salida o excepción.

🏭 Factory de mocks

createMockRepository(), createMockEventEmitter(). Centraliza la creación de mocks para no duplicar código en 20 specs.

En el próximo post cerramos el bloque de testing con tests E2E con Supertest: HTTP real, base de datos real en Docker, flujos completos de register -> login -> CRUD, fixtures y cómo integrarlo en un CI pipeline.

EA, nos vemos en los bares!! 🍺


Pon a prueba lo aprendido

1. ¿Cuál es la diferencia principal entre un test unitario y un test E2E?

2. ¿Para qué sirve getRepositoryToken(User) en un test de NestJS?

3. ¿Cuál es la diferencia entre jest.fn() y jest.spyOn()?

4. ¿Por qué se recomienda usar beforeEach en vez de beforeAll para montar el módulo de test?

5. ¿Por qué es mejor testear el comportamiento ('no guarda el password en plain text') que la implementación ('usa bcrypt con 10 rounds')?