Tester son application NestJS
Tests unitaires + E2E = confiance en production
NestJS est conçu pour ĂȘtre testable. Le conteneur DI permet d'injecter des mocks facilement. Jest est inclus par dĂ©faut, et @nestjs/testing fournit des utilitaires spĂ©ciaux.
Jest â bases
// Structure d'un test
describe('UsersService', () => {
// Setup (avant chaque test)
beforeEach(() => { ... });
afterEach(() => { ... });
// Setup global
beforeAll(() => { ... });
afterAll(() => { ... });
describe('create', () => {
it('should create a user', async () => {
// Arrange
const dto: CreateUserDto = { name: 'Alice', email: 'alice@test.com', password: '12345678A' };
// Act
const result = await service.create(dto);
// Assert
expect(result).toBeDefined();
expect(result.email).toBe(dto.email);
expect(result.id).toEqual(expect.any(Number));
});
it('should throw if email exists', async () => {
await expect(service.create(dto)).rejects.toThrow(ConflictException);
});
});
});
Matchers Jest courants
// ĂgalitĂ©
expect(value).toBe(42); // ===
expect(value).toEqual({ id: 1 }); // deep equal
expect(value).toStrictEqual({ ... }); // strict deep equal
// Véracité
expect(value).toBeTruthy();
expect(value).toBeFalsy();
expect(value).toBeNull();
expect(value).toBeUndefined();
expect(value).toBeDefined();
// Nombres
expect(5).toBeGreaterThan(3);
expect(5).toBeLessThanOrEqual(10);
expect(3.14159).toBeCloseTo(3.14, 2);
// Strings
expect('hello world').toContain('world');
expect('test@email.com').toMatch(/\w+@\w+\.\w+/);
// Arrays
expect([1, 2, 3]).toHaveLength(3);
expect([1, 2, 3]).toContain(2);
expect([{ id: 1 }]).toContainEqual({ id: 1 });
// Objects
expect(obj).toHaveProperty('name', 'Alice');
expect(obj).toMatchObject({ email: 'alice@test.com' }); // subset
// Exceptions
expect(() => fn()).toThrow(Error);
expect(fn).toThrowError('message');
await expect(asyncFn()).rejects.toThrow(NotFoundException);
Tests unitaires â Services
// users.service.spec.ts
import { Test, TestingModule } from '@nestjs/testing';
import { getRepositoryToken } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
describe('UsersService', () => {
let service: UsersService;
let userRepo: jest.Mocked<Repository<User>>;
const mockUser: User = {
id: 1, name: 'Alice', email: 'alice@test.com',
role: 'user', isActive: true, createdAt: new Date(), updatedAt: new Date(),
} as User;
beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
providers: [
UsersService,
{
provide: getRepositoryToken(User),
useValue: {
find: jest.fn(),
findOneBy: jest.fn(),
create: jest.fn(),
save: jest.fn(),
update: jest.fn(),
delete: jest.fn(),
},
},
],
}).compile();
service = module.get<UsersService>(UsersService);
userRepo = module.get(getRepositoryToken(User));
});
describe('findAll', () => {
it('should return array of users', async () => {
userRepo.find.mockResolvedValue([mockUser]);
const result = await service.findAll();
expect(result).toEqual([mockUser]);
expect(userRepo.find).toHaveBeenCalledTimes(1);
});
});
describe('findOne', () => {
it('should return a user by id', async () => {
userRepo.findOneBy.mockResolvedValue(mockUser);
const result = await service.findOne(1);
expect(result).toEqual(mockUser);
expect(userRepo.findOneBy).toHaveBeenCalledWith({ id: 1 });
});
it('should throw NotFoundException if user not found', async () => {
userRepo.findOneBy.mockResolvedValue(null);
await expect(service.findOne(999)).rejects.toThrow(NotFoundException);
});
});
describe('create', () => {
it('should create and return a user', async () => {
const dto: CreateUserDto = { name: 'Bob', email: 'bob@test.com', password: '12345678A' };
userRepo.create.mockReturnValue(mockUser);
userRepo.save.mockResolvedValue(mockUser);
const result = await service.create(dto);
expect(result).toEqual(mockUser);
expect(userRepo.create).toHaveBeenCalledWith(dto);
expect(userRepo.save).toHaveBeenCalledWith(mockUser);
});
});
});
Tests unitaires â Controllers
// users.controller.spec.ts
describe('UsersController', () => {
let controller: UsersController;
let service: jest.Mocked<UsersService>;
beforeEach(async () => {
const module = await Test.createTestingModule({
controllers: [UsersController],
providers: [
{
provide: UsersService,
useValue: {
findAll: jest.fn(),
findOne: jest.fn(),
create: jest.fn(),
update: jest.fn(),
remove: jest.fn(),
},
},
],
}).compile();
controller = module.get<UsersController>(UsersController);
service = module.get(UsersService);
});
describe('findAll', () => {
it('should return an array of users', async () => {
const mockUsers = [{ id: 1, name: 'Alice' }] as User[];
service.findAll.mockResolvedValue(mockUsers);
const result = await controller.findAll();
expect(result).toEqual(mockUsers);
expect(service.findAll).toHaveBeenCalled();
});
});
describe('create', () => {
it('should call service.create with the dto', async () => {
const dto: CreateUserDto = { name: 'Alice', email: 'alice@test.com', password: 'Pass123!' };
const mockResult = { id: 1, ...dto } as User;
service.create.mockResolvedValue(mockResult);
const result = await controller.create(dto);
expect(result).toEqual(mockResult);
expect(service.create).toHaveBeenCalledWith(dto);
});
});
});
Mocks avancés
// Mock d'un service avec jest.fn()
const mockMailService = {
sendWelcome: jest.fn().mockResolvedValue({ success: true }),
sendReset: jest.fn(),
};
// Mock d'un module complet
jest.mock('../mail/mail.service', () => ({
MailService: jest.fn().mockImplementation(() => ({
sendWelcome: jest.fn().mockResolvedValue(true),
})),
}));
// Spy sur une méthode existante
const spy = jest.spyOn(service, 'findOne').mockResolvedValue(mockUser);
expect(spy).toHaveBeenCalledWith(1);
spy.mockRestore(); // Restaurer l'implémentation originale
// Mock du ConfigService
{
provide: ConfigService,
useValue: {
get: jest.fn().mockImplementation((key: string) => {
const config = {
JWT_SECRET: 'test-secret',
DATABASE_URL: 'sqlite::memory:',
};
return config[key];
}),
},
}
Tests End-to-End (E2E)
// test/users.e2e-spec.ts
import * as request from 'supertest';
import { Test } from '@nestjs/testing';
import { INestApplication, ValidationPipe } from '@nestjs/common';
describe('UsersController (e2e)', () => {
let app: INestApplication;
let jwtToken: string;
beforeAll(async () => {
const moduleFixture = await Test.createTestingModule({
imports: [AppModule], // Module complet avec vraie DB (SQLite en mémoire)
}).compile();
app = moduleFixture.createNestApplication();
app.useGlobalPipes(new ValidationPipe({ whitelist: true, transform: true }));
await app.init();
// S'authentifier pour obtenir un token
const loginRes = await request(app.getHttpServer())
.post('/auth/login')
.send({ email: 'admin@test.com', password: 'Admin123!' });
jwtToken = loginRes.body.accessToken;
});
afterAll(async () => {
await app.close();
});
describe('GET /users', () => {
it('should return 401 without token', () => {
return request(app.getHttpServer())
.get('/users')
.expect(401);
});
it('should return users with valid token', () => {
return request(app.getHttpServer())
.get('/users')
.set('Authorization', `Bearer ${jwtToken}`)
.expect(200)
.expect((res) => {
expect(Array.isArray(res.body)).toBe(true);
});
});
});
describe('POST /users', () => {
it('should create a user', () => {
return request(app.getHttpServer())
.post('/users')
.send({ name: 'Test User', email: 'test@test.com', password: 'Test123!' })
.expect(201)
.expect((res) => {
expect(res.body).toHaveProperty('id');
expect(res.body.email).toBe('test@test.com');
expect(res.body).not.toHaveProperty('password');
});
});
it('should return 400 for invalid data', () => {
return request(app.getHttpServer())
.post('/users')
.send({ name: 'A', email: 'not-an-email', password: '123' })
.expect(400);
});
});
});
Coverage
# Lancer avec coverage
npm run test:cov
# Configuration dans package.json
"jest": {
"coverageThreshold": {
"global": {
"branches": 80,
"functions": 80,
"lines": 80,
"statements": 80
}
}
}
--------------------|---------|----------|---------|---------|
File | % Stmts | % Branch | % Funcs | % Lines |
--------------------|---------|----------|---------|---------|
All files | 92.43 | 87.50 | 91.67 | 92.43 |
users.service.ts | 95.00 | 90.00 | 100.00 | 95.00 |
users.controller.ts| 100.00 | 100.00 | 100.00 | 100.00 |
auth.service.ts | 88.89 | 80.00 | 90.00 | 88.89 |
--------------------|---------|----------|---------|---------|
Bonnes pratiques
Nommage des tests : Utilisez la convention
should [résultat attendu] when [condition] : "should throw NotFoundException when user not found".
Pyramid de tests : Beaucoup de tests unitaires rapides (80%), quelques tests d'intégration (15%), peu de tests E2E lents (5%). Les tests E2E testent les flux critiques complets.
Isolation : Chaque test doit ĂȘtre indĂ©pendant. Utilisez
beforeEach pour recréer les mocks, et évitez de partager l'état entre tests.
// Anti-pattern : ne pas tester les détails d'implémentation
it('should call repository.save() once', () => { ... }); // Fragile
// Bonne pratique : tester le comportement observable
it('should persist the user and return it with an id', async () => {
const result = await service.create(dto);
expect(result.id).toBeDefined();
expect(result.email).toBe(dto.email);
});