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);
});
✏ Exercices du module ▶ Mini-projet Module 10 →