Test de NestJS avec Vitest : modèles d'unité et d'intégration
Une application NestJS sans tests est un handicap. Vous ne pouvez pas refactoriser en toute confiance, vous ne pouvez pas ajouter de fonctionnalités sans rompre le comportement existant et vous ne pouvez pas détecter les régressions avant qu'elles n'atteignent la production. Mais écrire de mauvais tests est presque pire que pas de tests : des tests lents que personne n'exécute, des tests fragiles associés à des détails d'implémentation et des tests simulés qui ne reflètent pas le comportement réel érodent tous la confiance des développeurs.
Ce guide explique comment écrire des tests rapides et fiables pour NestJS à l'aide de Vitest, le remplacement moderne de Jest qui s'exécute en moins de 30 secondes pour plus de 1 300 tests. Nous couvrons tout le spectre : tests unitaires purs avec dépendances simulées, tests d'intégration avec une véritable transaction de base de données, tests de sécurité et configuration CI qui fait que tout s'exécute à chaque validation.
Points clés à retenir
- Vitest est compatible avec Jest mais 2 à 5 fois plus rapide — même API, pas de friction de migration
- Utilisez
vi.hoisted()pour les usines fictives afin d'éviter les problèmes de zone morte temporelle avecvi.mock()- Testez le comportement, pas l'implémentation - assertion sur les sorties et les effets secondaires, pas sur les appels de méthodes internes
- Les chaînes simulées Drizzle SELECT doivent se terminer par
.limit(), INSERT/UPDATE avec.returning()- Les tests d'intégration doivent utiliser des transactions de base de données réelles annulées après chaque test
- Utilisez
createTestingModulede NestJS pour des tests DI appropriés, pas pour une instanciation directe de classe- Les tests de sécurité (injection, contournement d'authentification, limite de débit) appartiennent à un répertoire
__security__distinct- Cibler une couverture de ligne de plus de 80 % pour la couche de service ; 100 % pour les modules d'authentification et de facturation
Configuration du test Vitest
// apps/api/vitest.config.ts
import { defineConfig } from 'vitest/config';
import swc from 'unplugin-swc';
export default defineConfig({
test: {
globals: true,
root: './',
coverage: {
provider: 'v8',
reporter: ['text', 'json', 'html', 'lcov'],
exclude: [
'node_modules/**',
'dist/**',
'**/*.spec.ts',
'**/*.dto.ts', // DTOs are just type definitions
'**/index.ts', // Barrel exports
],
thresholds: {
lines: 80,
functions: 80,
branches: 75,
},
},
reporters: ['verbose'],
maxConcurrency: 10,
pool: 'forks', // Better isolation for database tests
poolOptions: {
forks: { singleFork: false },
},
},
plugins: [
swc.vite({
module: { type: 'es6' },
}),
],
});
# Install
pnpm add -D vitest @vitest/coverage-v8 unplugin-swc @swc/core
# Run
cd apps/api && npx vitest run # All tests once
cd apps/api && npx vitest # Watch mode
cd apps/api && npx vitest run --coverage # With coverage report
Modèle de test unitaire
Service en cours de test
// src/modules/contacts/contacts.service.ts
@Injectable()
export class ContactsService {
constructor(
@InjectDatabase() private db: Database,
private readonly cacheService: CacheService,
) {}
async findAll(organizationId: string, page = 1, limit = 20) {
const cached = await this.cacheService.get(`contacts:${organizationId}:${page}`);
if (cached) return cached;
const result = await this.db.query.contacts.findMany({
where: eq(contacts.organizationId, organizationId),
limit,
offset: (page - 1) * limit,
orderBy: desc(contacts.createdAt),
});
await this.cacheService.set(`contacts:${organizationId}:${page}`, result, 300);
return result;
}
async create(organizationId: string, dto: CreateContactDto) {
const [contact] = await this.db
.insert(contacts)
.values({ ...dto, organizationId })
.returning();
return contact;
}
}
Test unitaire
// src/modules/contacts/contacts.service.spec.ts
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { Test } from '@nestjs/testing';
import { ContactsService } from './contacts.service';
import { CacheService } from '../cache/cache.service';
// vi.hoisted() ensures mock factories run before vi.mock() calls
const mockDb = vi.hoisted(() => ({
query: {
contacts: {
findMany: vi.fn(),
},
},
insert: vi.fn(),
}));
const mockCache = vi.hoisted(() => ({
get: vi.fn(),
set: vi.fn(),
}));
vi.mock('@ecosire/db', () => ({ DATABASE_TOKEN: 'DATABASE_TOKEN' }));
describe('ContactsService', () => {
let service: ContactsService;
beforeEach(async () => {
vi.clearAllMocks();
const module = await Test.createTestingModule({
providers: [
ContactsService,
{
provide: 'DATABASE_TOKEN',
useValue: mockDb,
},
{
provide: CacheService,
useValue: mockCache,
},
],
}).compile();
service = module.get<ContactsService>(ContactsService);
});
describe('findAll', () => {
it('returns cached result when cache hit', async () => {
const cached = [{ id: '1', name: 'Alice' }];
mockCache.get.mockResolvedValue(cached);
const result = await service.findAll('org_1');
expect(result).toEqual(cached);
expect(mockDb.query.contacts.findMany).not.toHaveBeenCalled();
});
it('queries database on cache miss and caches result', async () => {
const dbResult = [{ id: '1', name: 'Bob', organizationId: 'org_1' }];
mockCache.get.mockResolvedValue(null);
// Drizzle SELECT chains end with .limit()
mockDb.query.contacts.findMany.mockResolvedValue(dbResult);
const result = await service.findAll('org_1');
expect(result).toEqual(dbResult);
expect(mockCache.set).toHaveBeenCalledWith(
'contacts:org_1:1',
dbResult,
300
);
});
});
describe('create', () => {
it('inserts contact and returns created record', async () => {
const newContact = { id: '2', name: 'Carol', organizationId: 'org_1' };
// Drizzle INSERT chains end with .returning()
const returning = vi.fn().mockResolvedValue([newContact]);
const values = vi.fn().mockReturnValue({ returning });
mockDb.insert.mockReturnValue({ values });
const result = await service.create('org_1', { name: 'Carol' });
expect(result).toEqual(newContact);
expect(mockDb.insert).toHaveBeenCalled();
});
});
});
Proxy tenable pour les simulations de chaîne Drizzle
Les requêtes Drizzle sont des chaînes fluides. Pour les scénarios de tests d’intégration dans lesquels vous devez simuler la chaîne complète :
// test/helpers/drizzle-mock.ts
export function createDrizzleChainMock(resolveValue: unknown) {
const handler: ProxyHandler<object> = {
get(target, prop) {
if (prop === 'then') {
// Make the chain thenable so await works
return (resolve: (v: unknown) => void) => resolve(resolveValue);
}
// Return a proxy that returns itself for any method call
return (..._args: unknown[]) => new Proxy({}, handler);
},
};
return new Proxy({}, handler);
}
// Usage in tests
mockDb.select.mockReturnValue(createDrizzleChainMock([{ id: '1' }]));
Tests d'intégration avec une base de données réelle
// src/modules/__integration__/contacts.integration.spec.ts
import { describe, it, expect, beforeAll, afterAll, beforeEach } from 'vitest';
import { Test } from '@nestjs/testing';
import { drizzle } from 'drizzle-orm/postgres-js';
import postgres from 'postgres';
import { migrate } from 'drizzle-orm/postgres-js/migrator';
import * as schema from '@ecosire/db/schema';
import { ContactsModule } from '../contacts/contacts.module';
import { ContactsService } from '../contacts/contacts.service';
let sql: ReturnType<typeof postgres>;
let db: ReturnType<typeof drizzle>;
let service: ContactsService;
beforeAll(async () => {
// Use a separate test database
const testDbUrl = process.env.TEST_DATABASE_URL ||
'postgres://postgres:postgres@localhost:5433/ecosire_test';
sql = postgres(testDbUrl, { max: 1 });
db = drizzle(sql, { schema });
// Run migrations on test DB
await migrate(db, { migrationsFolder: './drizzle' });
const module = await Test.createTestingModule({
imports: [ContactsModule],
})
.overrideProvider('DATABASE_TOKEN')
.useValue(db)
.compile();
service = module.get<ContactsService>(ContactsService);
});
afterAll(async () => {
await sql.end();
});
beforeEach(async () => {
// Clean up test data between tests
await db.delete(schema.contacts);
});
describe('ContactsService (integration)', () => {
it('creates and retrieves a contact from the real database', async () => {
const created = await service.create('org_test_1', {
name: 'Integration Test User',
email: '[email protected]',
});
expect(created.id).toBeDefined();
expect(created.name).toBe('Integration Test User');
const all = await service.findAll('org_test_1');
expect(all).toHaveLength(1);
expect(all[0].id).toBe(created.id);
});
it('scopes results by organizationId (multi-tenancy)', async () => {
await service.create('org_1', { name: 'Org 1 Contact' });
await service.create('org_2', { name: 'Org 2 Contact' });
const org1Contacts = await service.findAll('org_1');
const org2Contacts = await service.findAll('org_2');
expect(org1Contacts).toHaveLength(1);
expect(org2Contacts).toHaveLength(1);
expect(org1Contacts[0].name).toBe('Org 1 Contact');
});
});
Tests du contrôleur
// src/modules/contacts/contacts.controller.spec.ts
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { Test } from '@nestjs/testing';
import { ContactsController } from './contacts.controller';
import { ContactsService } from './contacts.service';
describe('ContactsController', () => {
let controller: ContactsController;
const mockService = {
findAll: vi.fn(),
create: vi.fn(),
update: vi.fn(),
remove: vi.fn(),
};
beforeEach(async () => {
vi.clearAllMocks();
const module = await Test.createTestingModule({
controllers: [ContactsController],
providers: [
{ provide: ContactsService, useValue: mockService },
],
}).compile();
controller = module.get<ContactsController>(ContactsController);
});
it('calls service.findAll with organizationId from authenticated user', async () => {
const mockContacts = [{ id: '1', name: 'Test' }];
mockService.findAll.mockResolvedValue(mockContacts);
const mockUser = { id: 'user_1', organizationId: 'org_1', role: 'admin' };
const result = await controller.findAll(
{ user: mockUser } as AuthenticatedRequest,
1,
20
);
expect(result).toEqual(mockContacts);
expect(mockService.findAll).toHaveBeenCalledWith('org_1', 1, 20);
});
});
Tests de sécurité
// src/modules/__security__/injection.security.spec.ts
import { describe, it, expect } from 'vitest';
import request from 'supertest';
describe('SQL Injection Prevention', () => {
const injectionPayloads = [
"'; DROP TABLE contacts; --",
"' OR '1'='1",
"1; SELECT * FROM users",
"' UNION SELECT * FROM refresh_tokens --",
];
it.each(injectionPayloads)(
'should safely handle SQL injection attempt: %s',
async (payload) => {
const response = await request(app.getHttpServer())
.get('/contacts')
.query({ search: payload })
.set('Authorization', `Bearer ${validToken}`);
// Should return 200 with empty results or 400 for invalid input
// Should NEVER return 500 (which would indicate unhandled SQL error)
expect(response.status).not.toBe(500);
expect(response.body).not.toMatchObject({
message: expect.stringContaining('syntax error'),
});
}
);
});
describe('Authentication Bypass Prevention', () => {
it('should reject requests without authentication', async () => {
const response = await request(app.getHttpServer())
.get('/admin/contacts');
expect(response.status).toBe(401);
});
it('should reject tampered JWT', async () => {
const tamperedToken = validToken.slice(0, -5) + 'xxxxx';
const response = await request(app.getHttpServer())
.get('/contacts')
.set('Authorization', `Bearer ${tamperedToken}`);
expect(response.status).toBe(401);
});
it('should reject expired JWT', async () => {
// Token with exp in the past
const expiredToken = generateTokenWithExpiry(-3600);
const response = await request(app.getHttpServer())
.get('/contacts')
.set('Authorization', `Bearer ${expiredToken}`);
expect(response.status).toBe(401);
});
});
Configuration du pipeline CI
# .github/workflows/ci.yml
name: CI
on:
push:
branches: [main]
pull_request:
branches: [main]
jobs:
unit-tests:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v3
with:
version: 10
- uses: actions/setup-node@v4
with:
node-version: 22
cache: 'pnpm'
- run: pnpm install --frozen-lockfile
- run: pnpm build --filter=@ecosire/db --filter=@ecosire/types
- name: Run unit tests
run: cd apps/api && npx vitest run --coverage
env:
NODE_ENV: test
- name: Upload coverage
uses: codecov/codecov-action@v4
with:
files: apps/api/coverage/lcov.info
integration-tests:
runs-on: ubuntu-latest
services:
postgres:
image: postgres:17
env:
POSTGRES_DB: ecosire_test
POSTGRES_USER: postgres
POSTGRES_PASSWORD: postgres
ports:
- 5432:5432
options: >-
--health-cmd pg_isready
--health-interval 10s
--health-timeout 5s
--health-retries 5
steps:
- uses: actions/checkout@v4
- run: pnpm install --frozen-lockfile
- run: cd apps/api && npx vitest run src/modules/__integration__
env:
TEST_DATABASE_URL: postgres://postgres:postgres@localhost:5432/ecosire_test
Questions fréquemment posées
Pourquoi Vitest plutôt que Jest pour NestJS ?
Vitest utilise le bundler rapide de Vite (esbuild/Rollup) au lieu de Babel, permettant une exécution des tests 2 à 5 fois plus rapide. Il dispose d'une API compatible Jest — la plupart des tests Jest migrent sans modification. Vitest dispose d'un support ESM natif (important pour les packages modernes), d'un TypeScript intégré sans ts-jest et d'un meilleur mode de visualisation. Pour NestJS, le principal avantage réside dans des boucles de rétroaction considérablement plus rapides en mode montre.
Pourquoi utiliser vi.hoisted() pour les usines fictives ?
Les appels vi.mock() sont hissés en haut du fichier par le transformateur Vitest. Si votre usine fictive fait référence à une variable définie plus tard dans le fichier (comme un const mockDb = { ... }), elle s'exécute avant que la variable ne soit initialisée - une erreur de zone morte temporelle. vi.hoisted() crée des valeurs qui sont également hissées, afin qu'elles soient disponibles lorsque l'usine fictive s'exécute.
Comment tester les gardes et les intercepteurs NestJS ?
Créez un test d'intégration en utilisant createTestingModule avec overrideGuard() pour contourner ou tester la garde de manière isolée. Pour vérifier qu'un garde rejette réellement les demandes non autorisées, utilisez Supertest sur l'application complète (app.getHttpServer()) avec des jetons invalides ou manquants, et affirmez le code d'état 401/403 correct.
Les chaînes simulées Drizzle doivent-elles se terminer par .limit() ou .returning() ?
Pour les requêtes SELECT, la chaîne se termine généralement par findMany(), findFirst() ou un appel .limit(). Pour INSERT/UPDATE/DELETE avec values().returning(), la chaîne se termine par .returning(). Moquez-vous de la méthode la plus profonde de la chaîne, quel que soit le dernier code de service await. Le modèle de proxy pouvant être activé gère les chaînes de profondeur arbitraire.
Comment tester les tâches NestJS planifiées (décorateurs Cron) ?
Injectez le planificateur dans votre module de test et appelez directement la méthode de tâche — n'attendez pas le minuteur cron. Utilisez jest.useFakeTimers() (même API dans Vitest avec vi.useFakeTimers()) si vous devez tester le comportement de synchronisation. Pour vérifier qu'une tâche a été planifiée, vérifiez que le @nestjs/schedule SchedulerRegistry contient la tâche attendue.
Quelle est la structure de répertoires de test recommandée pour NestJS ?
Colocalisez les tests unitaires avec les fichiers sources (contacts.service.spec.ts à côté de contacts.service.ts). Placez les tests d'intégration dans src/modules/__integration__/ et les tests de sécurité dans src/modules/__security__/. Cette séparation vous permet d'exécuter uniquement des tests unitaires pendant le développement (vitest run --exclude **/__integration__/**) et tous les tests dans CI.
Prochaines étapes
Une suite de tests complète n’est pas un luxe : c’est l’infrastructure qui vous permet d’expédier plus rapidement en toute confiance. Avec Vitest exécutant 1 300 tests en moins de 30 secondes, il n'y a aucune excuse pour sauter le test avant de pousser.
ECOSIRE construit des backends NestJS avec une couverture complète des tests dès le premier jour : 76 fichiers de test, 1 301 tests unitaires et d'intégration, ainsi qu'une couverture de tests de sécurité pour l'injection, le contournement d'authentification et la limitation de débit. Découvrez nos services d'ingénierie backend pour découvrir comment nous fournissons des API testées et prêtes pour la production.
Rédigé par
ECOSIRE Research and Development Team
Création de produits numériques de niveau entreprise chez ECOSIRE. Partage d'analyses sur les intégrations Odoo, l'automatisation e-commerce et les solutions d'entreprise propulsées par l'IA.
Articles connexes
AI + ERP Integration: How AI is Transforming Enterprise Resource Planning
Learn how AI is transforming ERP systems in 2026—from intelligent automation and predictive analytics to natural language interfaces and autonomous operations.
All-in-One vs Best-of-Breed: The Software Stack Decision
All-in-one vs best-of-breed software strategy for 2026: integration complexity, total cost, vendor risk, and when each approach is right for your business.
The API Economy: Building an Integration-First Business
How to leverage the API economy for competitive advantage—building integration-first architecture, monetizing APIs, selecting iPaaS platforms, and creating ecosystem value.