Skip to content

Testing NestJS — unit, integration, e2e

TL;DR — Nest pousse une pyramide claire : unit (TestingModule + mocks), integration (DB/queue réels via testcontainers), e2e (Supertest sur l'app bootstrappée). Le piège ninja : confondre "unit" et "test du framework". Mock les frontières (DB, HTTP, clock, random), pas tes propres classes pures. Et compile une seule fois : cache: true + Jest projects sauvent 30–70% du temps CI.

🧠 Mental model — ASCII diagram + analogy

                e2e (slow, brittle)
                  /  HTTP  \
                 /----------\
              integration tests
              /   DB / Redis  \
             /------------------\
                unit tests
            /   pure logic + DI  \
           /----------------------\

Analogie : un test unitaire vérifie qu'une pièce marche isolée (un service avec ses deps mockées). Un integration test vérifie que deux pièces se parlent (service ↔ repository réel sur Postgres test). Un e2e teste la maison entière depuis la porte d'entrée (HTTP in → HTTP out). Plus tu montes la pyramide, plus c'est cher → plus c'est rare.

Règle d'or : un bug doit faire échouer un seul test clairement nommé. Si un bug fait crasher 50 e2e, ta pyramide est inversée.

Taxonomie des test doubles (le vocabulaire que tu DOIS poser juste)

« Mock » est utilisé à tort pour tout. Un staff distingue cinq doubles (Meszaros / Fowler) car ils n'ont pas le même couplage au code :

DoubleCe qu'il faitCouplageQuand
DummyObjet passé mais jamais utilisé (remplit une signature)nulcombler un paramètre non pertinent
StubRenvoie des réponses figées (state-based)faiblerepo.findById.mockResolvedValue(user)
SpyStub + enregistre les appels pour assertion a posteriorimoyenvérifier qu'un événement a été publié
MockAttentes pré-programmées, échoue s'il n'est pas appelé comme prévu (interaction-based)fortvérifier un protocole précis (ordre, args)
FakeImplémentation réelle mais simplifiée (in-memory DB, faux clock)faibleInMemoryEventBus, pg-mem

Mental model du staff : préfère stub + spy (state-based) aux mocks stricts (interaction-based). Un mock strict couple ton test à l'implémentation (« il appelle save exactement 1 fois avec ces args »). Quand tu refactores sans changer le comportement, le test casse → c'est un test fragile (brittle). Tu testes alors comment le code marche, pas ce qu'il fait. Réserve l'assertion d'interaction aux effets de bord observables uniquement par l'interaction : « un email a-t-il été envoyé ? », « l'événement transfer.settled a-t-il été publié ? ». Pour tout le reste, assertionne l'état de sortie.

Loi de Steve Freeman : « Don't mock what you don't own. » Ne mocke jamais directement le SDK Stripe/Anthropic — enveloppe-le dans ton port (PaymentGateway, LlmClient) et mocke le port. Sinon ton mock encode tes suppositions sur l'API tierce, et il reste vert pendant que la prod casse.

🛠️ Code minimal — realistic snippet

Setup Jest (monorepo-friendly)

ts
// jest.config.ts
import type { Config } from 'jest';

const config: Config = {
  preset: 'ts-jest',
  testEnvironment: 'node',
  moduleFileExtensions: ['js', 'json', 'ts'],
  rootDir: '.',
  testRegex: '.*\\.spec\\.ts$',
  transform: { '^.+\\.(t|j)s$': ['ts-jest', { isolatedModules: true }] },
  collectCoverageFrom: ['src/**/*.(t|j)s', '!src/**/*.module.ts'],
  coverageThreshold: {
    global: { branches: 80, functions: 85, lines: 85, statements: 85 },
  },
  moduleNameMapper: { '^@/(.*)$': '<rootDir>/src/$1' },
  // ⚠️ La bonne clé Jest est `setupFilesAfterEnv` (s'exécute APRÈS le framework, AVANT chaque fichier de test).
  // `setupFilesAfterEach` N'EXISTE PAS — Jest l'ignore silencieusement et ton setup ne tourne jamais.
  setupFilesAfterEnv: ['<rootDir>/test/setup.ts'],
  clearMocks: true,        // reset des mock.calls entre tests — évite la contamination cross-test
  // restoreMocks: true,   // restaure aussi les implémentations spy'ées (à activer si tu utilises jest.spyOn)
};
export default config;

isolatedModules: true divise par 2–4 le temps de compilation TS car ts-jest transpile fichier par fichier sans type-checking (c'est tsc --noEmit / vue-tsc en CI qui doit attraper les erreurs de types, pas Jest). Tu perds donc le filet « un type cassé fait échouer le test » — assume-le et garde un job typecheck séparé.

clearMocks vs resetMocks vs restoreMocks — la confusion la plus courante. clearMocks vide mock.calls/mock.results (compteurs) mais garde l'implémentation. resetMocks enlève aussi l'implémentation (le mock redevient () => undefined). restoreMocks ne touche que les jest.spyOn et restaure l'original. Active clearMocks: true globalement ; n'active resetMocks que si tu redéfinis l'implémentation dans chaque it, sinon tes mockResolvedValue de beforeEach sont effacés et tu débugges un undefined mystérieux pendant 20 minutes.

Unit test avec TestingModule

ts
// users.service.spec.ts
import { Test } from '@nestjs/testing';
import { UsersService } from './users.service';
import { UsersRepository } from './users.repository';

describe('UsersService', () => {
  let service: UsersService;
  let repo: jest.Mocked<UsersRepository>;

  beforeEach(async () => {
    const module = await Test.createTestingModule({
      providers: [
        UsersService,
        {
          provide: UsersRepository,
          useValue: { findById: jest.fn(), save: jest.fn() },
        },
      ],
    }).compile();

    service = module.get(UsersService);
    repo = module.get(UsersRepository);
  });

  it('throws when user not found', async () => {
    repo.findById.mockResolvedValue(null);
    await expect(service.activate('abc')).rejects.toThrow('User not found');
  });
});

e2e avec Supertest

ts
// app.e2e-spec.ts
import { Test } from '@nestjs/testing';
import { INestApplication, ValidationPipe } from '@nestjs/common';
import request from 'supertest';
import { AppModule } from '../src/app.module';

describe('App (e2e)', () => {
  let app: INestApplication;

  beforeAll(async () => {
    const moduleRef = await Test.createTestingModule({ imports: [AppModule] }).compile();
    app = moduleRef.createNestApplication();
    app.useGlobalPipes(new ValidationPipe({ whitelist: true, transform: true }));
    await app.init();
  });

  afterAll(() => app.close());

  it('GET /health -> 200', () => request(app.getHttpServer()).get('/health').expect(200));
});

Integration avec Testcontainers

ts
// users.integration.spec.ts
import { GenericContainer, StartedTestContainer } from 'testcontainers';
import { DataSource } from 'typeorm';

describe('UsersRepository (integration)', () => {
  let container: StartedTestContainer;
  let ds: DataSource;

  beforeAll(async () => {
    container = await new GenericContainer('postgres:16-alpine')
      .withEnvironment({ POSTGRES_PASSWORD: 'pw', POSTGRES_DB: 'test' })
      .withExposedPorts(5432)
      .start();

    ds = new DataSource({
      type: 'postgres',
      host: container.getHost(),
      port: container.getMappedPort(5432),
      username: 'postgres',
      password: 'pw',
      database: 'test',
      synchronize: true,
      entities: [/* ... */],
    });
    await ds.initialize();
  }, 60_000);

  afterAll(async () => {
    await ds.destroy();
    await container.stop();
  });

  it('persists and reads back', async () => {
    /* ... */
  });
});

🎯 Patterns courants

  1. Override providers via overrideProvider().useValue() — pour e2e qui doivent shunter un service externe (Stripe, S3) sans toucher au module métier.
  2. Builder de fixturesuserBuilder().withRole('admin').build(). Évite les { id: 1, name: 'foo', ... } éparpillés. Plug-in : @faker-js/faker pour les champs random non significatifs.
  3. In-memory adapters pour les ports — ex. InMemoryEventBus qui implémente EventBus. Tu testes la logique métier sans Redis.
  4. Test slices — un TestingModule par feature au lieu de bootstrap AppModule entier. Coût de boot e2e divisé par 5–10.
  5. Faux clock (jest.useFakeTimers({ now: new Date('2025-01-01') })) pour TTL, scheduling, JWT exp.
  6. Snapshot ciblé — JSON normalisé d'un body de réponse, pas un dump de DOM ou de stack. Et commit le snapshot dans la PR avec la PR qui le change, jamais en "fix tests" séparé.

🔄 Versions — Nest 7 / 8 / 9 / 10 / 11

  • 7 → 8 : @nestjs/testing accepte useFactory async. createTestingModule retourne TestingModuleBuilder.
  • 9 : meilleur support Logger en test (new Logger() peut être surchargé via setLogger).
  • 10 : app.close() exécute tous les OnModuleDestroy ; avant, les providers transient n'étaient pas toujours nettoyés.
  • 11 : compatibilité Jest 29 / Vitest officielle (préset @nestjs/testing reste Jest mais Vitest fonctionne, juste rebrancher vi au lieu de jest).
  • Côté Supertest : v7 utilise node-fetch, change le typage des erreurs réseau (timeouts → TypeError au lieu de Error).

⚠️ Pitfalls

  1. Bootstraper AppModule entier dans chaque spec — temps de CI explosé. Utilise des modules ciblés.
  2. Mock du logger Nest — si tu oublies, certains tests crachent du bruit. app.useLogger(false) en e2e.
  3. forwardRef non résolu en testTestingModule ne charge pas l'ordre paresseux. Symptôme : Nest can't resolve dependencies alors qu'au runtime ça marche. Solution : overrideProvider côté A pour casser le cycle.
  4. Connexions DB non fermées → Jest hang à la fin. Toujours afterAll(() => app.close()). Active --detectOpenHandles une fois pour identifier.
  5. Snapshot qui contient des dates → flaky. Sérialise avec un replacer ou utilise expect.objectContaining.
  6. Tests parallèles + DB partagée → race conditions. Soit Jest --runInBand, soit un schéma Postgres par worker (POSTGRES_SCHEMA: workerId).
  7. ValidationPipe global oublié en e2e — tes tests passent, prod échoue à 400. Inversement, l'oublier rend les DTO inertes.
  8. request(app.getHttpServer()) au lieu de app.listen() — préfère le premier, sinon tu allumes un port réel et tu fuites entre tests.

🧪 Testing — contract testing intro

Pour les API consommées par d'autres équipes, ajoute du consumer-driven contract testing :

ts
// Pact consumer test (extrait)
import { PactV3 } from '@pact-foundation/pact';

const provider = new PactV3({ consumer: 'web', provider: 'users-api' });

provider
  .uponReceiving('a request for user 42')
  .withRequest({ method: 'GET', path: '/users/42' })
  .willRespondWith({
    status: 200,
    body: { id: 42, email: '[email protected]' },
  });

await provider.executeTest(async (mock) => {
  const res = await fetch(`${mock.url}/users/42`);
  expect(res.status).toBe(200);
});

Côté Nest, un test "provider verification" rejoue les interactions Pact contre l'app bootstrapée. Tu attrapes les ruptures de contrat avant le déploiement.

🤖 Tester un endpoint qui SERT un agent LLM (NestJS)

C'est la partie que les docs officielles ne couvrent pas et que ton stack (Python + NestJS + Angular servant/consommant des agents) exige. Trois choses sont non déterministes et cassent les tests naïfs : (1) le contenu généré, (2) le streaming, (3) le tool-use loop. La discipline : mocke le LlmClient (ton port), pas le réseau d'Anthropic, et teste le protocole que ton serveur impose, pas la prose du modèle.

1. Le client LLM est injecté, jamais new Anthropic() dans un champ

ts
// llm.module.ts — DI'd via forRootAsync, mockable en test
import Anthropic from '@anthropic-ai/sdk';
import { Module } from '@nestjs/common';

export const LLM_CLIENT = Symbol('LLM_CLIENT');

@Module({
  providers: [
    {
      provide: LLM_CLIENT,
      inject: [ConfigService],
      useFactory: (cfg: ConfigService) =>
        new Anthropic({
          apiKey: cfg.getOrThrow('ANTHROPIC_API_KEY'),
          maxRetries: 4,          // le SDK retry les 429/5xx avec backoff — ne le réimplémente pas
          timeout: 60_000,
        }),
    },
  ],
  exports: [LLM_CLIENT],
})
export class LlmModule {}

En test, on overrideProvider(LLM_CLIENT) avec un fake déterministe. Modèles à citer dans les configs/tests : claude-opus-4-8 (flagship), claude-sonnet-4-6 (équilibré), claude-haiku-4-5 (rapide/cheap). Ne hardcode jamais l'id dans le service — passe-le par config pour tester le routing de modèle.

2. Tester le streaming SSE sans dépendre du contenu

Le test n'assertionne pas « le modèle a dit bonjour » (non déterministe) mais le contrat de transport : ordre des events, flush incrémental, event terminal, et fermeture propre.

ts
// chat.controller.streaming.spec.ts
import { Test } from '@nestjs/testing';
import request from 'supertest';
import { LLM_CLIENT } from '../llm/llm.module';

// Fake client : émet un AsyncIterable de deltas déterministe
const fakeStream = async function* () {
  yield { type: 'content_block_delta', delta: { type: 'text_delta', text: 'Hel' } };
  yield { type: 'content_block_delta', delta: { type: 'text_delta', text: 'lo' } };
  yield { type: 'message_stop' };
};

it('streams tokens as SSE then closes', async () => {
  const moduleRef = await Test.createTestingModule({ imports: [AppModule] })
    .overrideProvider(LLM_CLIENT)
    .useValue({ messages: { stream: () => fakeStream() } })
    .compile();
  const app = moduleRef.createNestApplication();
  await app.init();

  const res = await request(app.getHttpServer())
    .post('/chat')
    .send({ prompt: 'hi' })
    .buffer(true)
    .parse((r, cb) => {
      let data = '';
      r.on('data', (c) => (data += c));
      r.on('end', () => cb(null, data));
    });

  expect(res.headers['content-type']).toContain('text/event-stream');
  // contrat de transport, PAS le contenu du modèle :
  expect(res.text).toMatch(/data: Hel\n\n/);
  expect(res.text).toMatch(/data: lo\n\n/);
  expect(res.text).toMatch(/event: done/);
  await app.close();
});

3. Tester le tool-use loop agentique (le vrai piège)

Un agent serveur boucle : appel modèle → si stop_reason: 'tool_use', exécute l'outil → réinjecte le tool_result → rappelle le modèle, jusqu'à end_turn. Tu testes : terminaison (pas de boucle infinie), le bon outil appelé avec les bons args, et la coupure sur déconnexion client (AbortController).

ts
// agent-loop.spec.ts
it('runs the tool, feeds the result back, then stops', async () => {
  // 1er appel -> demande l'outil ; 2e appel -> répond
  const llm = {
    messages: {
      create: jest.fn()
        .mockResolvedValueOnce({
          stop_reason: 'tool_use',
          content: [{ type: 'tool_use', id: 't1', name: 'get_balance', input: { acc: 'a1' } }],
        })
        .mockResolvedValueOnce({
          stop_reason: 'end_turn',
          content: [{ type: 'text', text: 'Ton solde est 100€.' }],
        }),
    },
  };
  const tools = { get_balance: jest.fn().mockResolvedValue({ balance: 100 }) };

  const out = await runAgentLoop({ llm, tools, prompt: 'solde ?', maxSteps: 5 });

  expect(tools.get_balance).toHaveBeenCalledWith({ acc: 'a1' });
  expect(llm.messages.create).toHaveBeenCalledTimes(2);   // un seul aller-retour
  expect(out.text).toContain('100');
});

it('aborts mid-loop when the client disconnects', async () => {
  const ac = new AbortController();
  const llm = { messages: { create: jest.fn(async ({ signal }) => {
    ac.abort();                              // simule un disconnect entre deux steps
    if (signal?.aborted) throw new DOMException('Aborted', 'AbortError');
    return { stop_reason: 'end_turn', content: [] };
  }) } };

  await expect(
    runAgentLoop({ llm, tools: {}, prompt: 'x', signal: ac.signal, maxSteps: 5 }),
  ).rejects.toThrow(/Abort/);
});

it('caps the loop so a misbehaving model cannot run forever', async () => {
  const llm = { messages: { create: jest.fn().mockResolvedValue({
    stop_reason: 'tool_use',
    content: [{ type: 'tool_use', id: 't', name: 'noop', input: {} }],
  }) } };
  await expect(runAgentLoop({ llm, tools: { noop: jest.fn() }, prompt: 'x', maxSteps: 3 }))
    .rejects.toThrow(/max steps/);
  expect(llm.messages.create).toHaveBeenCalledTimes(3);   // borné, pas infini
});

4. BullMQ : job AI idempotent + cost-aware

Pour les générations longues, tu enqueues. Le test prouve l'idempotence keyée sur une generationId (un retry BullMQ ne re-facture pas un appel modèle) et le partial-output (un job tué à mi-stream reprend, ne recommence pas de zéro).

ts
it('does not re-call the model when the generation already completed', async () => {
  await cache.set('gen:g-1', { status: 'done', text: 'cached' });
  const llm = { messages: { create: jest.fn() } };

  const r = await processor.handle({ data: { generationId: 'g-1', prompt: 'x' }, llm } as any);

  expect(r.text).toBe('cached');
  expect(llm.messages.create).not.toHaveBeenCalled();   // zéro coût sur retry
});

Ce qu'on n'assertionne JAMAIS : la valeur exacte du texte généré, le nombre de tokens, ou un score sémantique dans un test unitaire. Le non-déterminisme rend ça flaky. Les évaluations de qualité de sortie (LLM-as-judge, golden datasets) vivent dans une suite eval séparée, taggée @eval, exclue de la CI bloquante et tournée en nightly — elle mesure une tendance, pas un pass/fail binaire.

🎬 Cas d'usage concrets

Scénario 1 — Tests d'intégration d'un connecteur Pennylane (cabinet d'expertise comptable)

Qui : éditeur d'un outil de pré-saisie qui pousse des écritures vers Pennylane pour 400 cabinets clients. Problème : l'API Pennylane bouge (rate limits, codes d'erreur), et un mock incomplet a déjà laissé passer en prod un bug qui doublonnait les écritures. La direction veut des tests d'intégration qui rejouent des cassettes HTTP réelles, pas des mocks de complaisance.

ts
// pennylane-client.integration.spec.ts
import { Test } from '@nestjs/testing';
import { setupServer } from 'msw/node';
import { http, HttpResponse } from 'msw';
import { PennylaneClient } from './pennylane.client';
import { JournalEntriesService } from './journal-entries.service';

const server = setupServer(
  http.post('https://app.pennylane.com/api/external/v1/journal_entries', async ({ request }) => {
    const body = (await request.json()) as { idempotency_key: string };
    if (body.idempotency_key === 'dup-key') {
      return HttpResponse.json({ id: 'je_42', already_exists: true }, { status: 200 });
    }
    return HttpResponse.json({ id: 'je_43' }, { status: 201 });
  }),
);

describe('JournalEntriesService -> Pennylane', () => {
  let service: JournalEntriesService;

  beforeAll(() => server.listen({ onUnhandledRequest: 'error' }));
  afterAll(() => server.close());

  beforeEach(async () => {
    const moduleRef = await Test.createTestingModule({
      providers: [JournalEntriesService, PennylaneClient],
    }).compile();
    service = moduleRef.get(JournalEntriesService);
  });

  it('does not duplicate when idempotency key already used', async () => {
    const first = await service.push({ key: 'dup-key', lines: [] });
    const second = await service.push({ key: 'dup-key', lines: [] });
    expect(first.id).toBe('je_42');
    expect(second.id).toBe(first.id);
  });
});

Gains : le cabinet voit zéro doublon en prod depuis 9 mois, le test rattrape automatiquement les changements de schéma Pennylane (un champ renommé fait échouer la cassette), et l'équipe support n'est plus appelée pour des écritures fantômes.

Scénario 2 — Tests e2e d'un checkout e-commerce avec inventaire

Qui : marketplace B2C française, panier moyen 120 €, 8 000 commandes/jour, intégration Stripe + Colissimo. Problème : le checkout enchaîne 4 étapes (reservation stock, paiement Stripe, création shipment Colissimo, email). Un bug à n'importe quelle étape doit rollback proprement. Les tests unitaires passaient, mais en prod un timeout Stripe a laissé du stock réservé et de l'argent prélevé sans commande.

ts
// checkout.e2e-spec.ts
describe('POST /checkout (e2e)', () => {
  it('rolls back stock reservation when Stripe fails', async () => {
    // Stub Stripe to throw after 200ms (simulate timeout window)
    nock('https://api.stripe.com')
      .post('/v1/payment_intents')
      .delay(200)
      .replyWithError({ code: 'ETIMEDOUT' });

    const before = await stockRepo.findOne({ sku: 'SKU-001' });
    const res = await request(app.getHttpServer())
      .post('/checkout')
      .send({ items: [{ sku: 'SKU-001', qty: 2 }], paymentMethod: 'pm_card_visa' });

    expect(res.status).toBe(502);
    const after = await stockRepo.findOne({ sku: 'SKU-001' });
    expect(after!.available).toBe(before!.available); // released
    expect(await reservationRepo.count()).toBe(0);
  });
});

Gains : la régression est attrapée en CI à chaque PR. L'équipe a découvert deux autres chemins où le rollback était partiel (le shipment Colissimo créé avant l'email échouait sans annuler le shipment) — corrigés avant prod.

Scénario 3 — Tests d'un service KYC bancaire avec scoring tiers

Qui : néobanque qui onboarde 2 000 nouveaux clients/jour, scoring KYC via deux fournisseurs (un OCR, un screening PEP/sanctions). Problème : impossible de tester en CI contre les vrais fournisseurs (coût + secret leak). Mais les mocks naïfs cachent les vraies réponses (un score 0.42 d'un fournisseur ne signifie pas pareil chez l'autre). Le compliance officer exige des tests reproductibles qui prouvent les décisions.

ts
// kyc-decision.spec.ts
describe('KycDecisionService', () => {
  it.each([
    { ocrScore: 0.95, pepHits: 0, expected: 'approved' },
    { ocrScore: 0.95, pepHits: 1, expected: 'manual_review' },
    { ocrScore: 0.40, pepHits: 0, expected: 'manual_review' },
    { ocrScore: 0.40, pepHits: 3, expected: 'rejected' },
  ])('decides $expected for ocr=$ocrScore pep=$pepHits', async ({ ocrScore, pepHits, expected }) => {
    ocrClient.score.mockResolvedValue({ score: ocrScore });
    pepClient.search.mockResolvedValue({ hits: Array(pepHits).fill({}) });
    const decision = await service.evaluate({ applicantId: 'a1' });
    expect(decision.outcome).toBe(expected);
    expect(decision.reasonCodes).toMatchSnapshot();
  });
});

Gains : la matrice de décision devient documentation vivante pour l'auditeur ACPR. Tout changement de seuil casse un test nommé explicitement, donc impossible de bouger une règle métier sans review compliance.

🛠️ Exemple end-to-end

Mise en situation : tu testes une fonctionnalité de virement programmé dans une néobanque PME. Le service doit créditer/débiter deux comptes en transaction, publier un événement sur la queue, et envoyer une notification. Tu veux un test d'intégration qui exerce le repository réel (Postgres via testcontainers), mocke Stripe et la messagerie, et vérifie le comportement complet — y compris l'idempotence.

ts
// scheduled-transfer.integration.spec.ts
import { Test } from '@nestjs/testing';
import { GenericContainer, StartedTestContainer } from 'testcontainers';
import { DataSource } from 'typeorm';
import { Account } from '../src/accounts/account.entity';
import { Transfer } from '../src/transfers/transfer.entity';
import { TransfersModule } from '../src/transfers/transfers.module';
import { TransfersService } from '../src/transfers/transfers.service';
import { NotificationsGateway } from '../src/notifications/notifications.gateway';
import { EventBus } from '../src/events/event-bus';

describe('ScheduledTransfer (integration)', () => {
  let container: StartedTestContainer;
  let ds: DataSource;
  let service: TransfersService;
  const notify = { send: jest.fn() } as unknown as NotificationsGateway;
  const bus = { publish: jest.fn() } as unknown as EventBus;

  beforeAll(async () => {
    container = await new GenericContainer('postgres:16-alpine')
      .withEnvironment({ POSTGRES_PASSWORD: 'pw', POSTGRES_DB: 'bank' })
      .withExposedPorts(5432)
      .start();

    ds = new DataSource({
      type: 'postgres',
      host: container.getHost(),
      port: container.getMappedPort(5432),
      username: 'postgres',
      password: 'pw',
      database: 'bank',
      synchronize: true,
      entities: [Account, Transfer],
    });
    await ds.initialize();

    const moduleRef = await Test.createTestingModule({
      imports: [TransfersModule],
    })
      .overrideProvider(DataSource).useValue(ds)
      .overrideProvider(NotificationsGateway).useValue(notify)
      .overrideProvider(EventBus).useValue(bus)
      .compile();

    service = moduleRef.get(TransfersService);
  }, 60_000);

  afterAll(async () => {
    await ds.destroy();
    await container.stop();
  });

  beforeEach(async () => {
    await ds.getRepository(Transfer).clear();
    await ds.getRepository(Account).clear();
    jest.clearAllMocks();
    await ds.getRepository(Account).insert([
      { id: 'acc_src', balance: 100_000, currency: 'EUR' },
      { id: 'acc_dst', balance: 0, currency: 'EUR' },
    ]);
  });

  it('debits source, credits destination, emits event and notifies', async () => {
    const result = await service.execute({
      idempotencyKey: 'k-1',
      from: 'acc_src',
      to: 'acc_dst',
      amount: 25_000,
      scheduledAt: new Date('2026-05-23T09:00:00Z'),
    });

    expect(result.status).toBe('settled');
    const src = await ds.getRepository(Account).findOneByOrFail({ id: 'acc_src' });
    const dst = await ds.getRepository(Account).findOneByOrFail({ id: 'acc_dst' });
    expect(src.balance).toBe(75_000);
    expect(dst.balance).toBe(25_000);
    expect(bus.publish).toHaveBeenCalledWith(
      'transfer.settled',
      expect.objectContaining({ transferId: result.id, amount: 25_000 }),
    );
    expect(notify.send).toHaveBeenCalledWith('acc_src', expect.stringContaining('25 000'));
  });

  it('is idempotent on retry with same key', async () => {
    const first = await service.execute({ idempotencyKey: 'k-2', from: 'acc_src', to: 'acc_dst', amount: 1_000, scheduledAt: new Date() });
    const second = await service.execute({ idempotencyKey: 'k-2', from: 'acc_src', to: 'acc_dst', amount: 1_000, scheduledAt: new Date() });

    expect(second.id).toBe(first.id);
    const src = await ds.getRepository(Account).findOneByOrFail({ id: 'acc_src' });
    expect(src.balance).toBe(99_000); // débité une seule fois
    expect(bus.publish).toHaveBeenCalledTimes(1);
  });

  it('rolls back when destination account is frozen', async () => {
    await ds.getRepository(Account).update({ id: 'acc_dst' }, { frozen: true });

    await expect(
      service.execute({ idempotencyKey: 'k-3', from: 'acc_src', to: 'acc_dst', amount: 5_000, scheduledAt: new Date() }),
    ).rejects.toThrow(/account_frozen/);

    const src = await ds.getRepository(Account).findOneByOrFail({ id: 'acc_src' });
    const dst = await ds.getRepository(Account).findOneByOrFail({ id: 'acc_dst' });
    expect(src.balance).toBe(100_000);
    expect(dst.balance).toBe(0);
    expect(bus.publish).not.toHaveBeenCalled();
  });
});

Ce test exerce le repository réel (Postgres dans un container jetable), la transaction DB, l'idempotence (relayée par la contrainte unique sur idempotency_key), et trois invariants métier d'un coup : double-entrée comptable, dédoublonnage, rollback sur compte gelé. La durée totale en local : ~12s incluant le démarrage du container, ~3s en CI avec un container réutilisé entre suites via --testEnvironment partagé.

🔁 Quand utiliser / éviter

  • Unit : logique métier pure, parsers, mappers, calculs. Évite quand 90% est mock — c'est que ta classe ne fait rien.
  • Integration : repositories, gateways, message handlers. Évite pour valider un calcul pur (couvert par l'unit).
  • e2e : flux critiques (paiement, signup, auth), 5–20 max. Évite pour la régression visuelle — c'est le job du front.
  • Contract : APIs publiques ou cross-team. Évite pour les APIs internes consommées par un seul service.
  • Snapshot : sérialisations stables (OpenAPI, AST). Évite pour des objets avec dates, IDs, ordre non garanti.

🩹 Flakiness — le tueur silencieux de la confiance

Un test flaky (vert/rouge sans changement de code) est pire qu'un test absent : il érode la confiance jusqu'à ce que l'équipe retry-jusqu'au-vert, et le jour où il attrape un vrai bug, personne ne le croit. Le staff traite la flakiness comme un incident de prod, pas comme une nuisance.

Les 6 sources, par fréquence réelle :

SourceSymptômeFix
Temps réelDate.now(), TTL, JWT expjest.useFakeTimers({ now }) + injecte une Clock
Ordre non garantifind() sans ORDER BY, Object.keystrie explicitement avant assertion
État partagétests passent seuls, échouent en suiteclearMocks, truncate DB en beforeEach, pas de singleton mutable
Async non attendupromesse orpheline, setTimeout non flushéawait partout, --detectOpenHandles, waitFor au lieu de sleep
Concurrence/portsflaky uniquement en CI parallèleschéma/DB par worker, port random (testcontainers le fait)
Réseau réeltimeout aléatoireonUnhandledRequest: 'error' (msw) — interdit toute fuite réseau

Mental model : un test ne doit dépendre que de ses entrées explicites. Toute dépendance implicite (horloge, ordre d'insertion, état d'un test précédent, réseau) est une fuite de déterminisme. Le onUnhandledRequest: 'error' de msw est l'outil le plus sous-utilisé : il transforme « un test a oublié de mocker un appel HTTP » en échec immédiat plutôt qu'en flaky intermittent.

Observabilité de la flakiness (échelle équipe) : ne « retry 3x » pas en aveugle. Instrumente — jest --json → un reporter qui pousse { test, passed, durationMs, retries } vers ta stack (Datadog/Grafana). Tu obtiens un flaky rate par test ; au-delà d'un seuil, le test est quarantiné (taggé @flaky, sorti du gate bloquant, ticket auto). C'est exactement ce que font les pipelines de Google/Spotify. La règle inverse aussi : un test qui n'a jamais échoué en 6 mois et tourne lentement est peut-être un test qui ne teste rien — candidat à la suppression.

Parallelization — Jest projects + sharding

Quand le repo grossit, le jest mono devient lent (1 process = 1 worker effectif). Trois leviers :

  1. --maxWorkers=50% — Jest crée un pool en parallèle. Bon défaut sur ta machine. En CI, dépend du CPU alloué.
  2. Jest projects — un jest.config racine qui référence N sub-configs, chacun avec son displayName. Permet d'isoler les setups (unit / integration / e2e).
  3. Sharding CIjest --shard=1/4 à --shard=4/4 réparti la suite sur 4 jobs CI parallèles. Total wall-clock divisé par 4.
ts
// jest.config.ts (root)
export default {
  projects: [
    '<rootDir>/jest.unit.config.ts',
    '<rootDir>/jest.integration.config.ts',
    '<rootDir>/jest.e2e.config.ts',
  ],
};
yaml
# .github/workflows/test.yml (extrait)
strategy:
  matrix:
    shard: [1, 2, 3, 4]
steps:
  - run: pnpm jest --shard=${{ matrix.shard }}/4 --ci --maxWorkers=2

Avec testcontainers, attention au port mapping : chaque worker doit avoir sa propre instance Postgres (testcontainers donne un port random, parfait). Ou un schema par worker sur une seule instance.

Test fixtures — builder pattern

Évite les objets fixtures dispersés dans 50 fichiers de test. Centralise via builders :

ts
// test/builders/user.builder.ts
export const userBuilder = (overrides: Partial<User> = {}): User => ({
  id: randomUUID(),
  email: `user-${Date.now()}@test.local`,
  role: 'user',
  createdAt: new Date('2025-01-01'),
  ...overrides,
});

export const adminBuilder = () => userBuilder({ role: 'admin' });

Couplé à une factory DB (@mikro-orm/seeder ou maison) pour les tests d'intégration. Une convention : les builders sont purs (pas d'I/O), les factories persistent.

📏 Couverture de code — la métrique qui ment

Le piège staff le plus coûteux politiquement : un manager qui exige « 90% de coverage ». La couverture de lignes mesure ce qui a été exécuté, jamais ce qui a été vérifié. Le contre-exemple canonique tient en trois lignes :

ts
it('does something', async () => {
  await service.computeRiskScore(applicant); // exécute 100% des lignes
  // ZÉRO assertion → 100% coverage, 0% de valeur
});

Ce test passe, illumine ton rapport de coverage en vert, et ne casserait jamais si computeRiskScore renvoyait NaN. La couverture de lignes est une borne inférieure sur ce que tu testes : elle dit « ce code n'a jamais tourné » (utile) mais pas « ce code est correct » (l'illusion).

La hiérarchie des métriques, du plus faible au plus fort :

MétriqueCe qu'elle prouveFaiblesse
Line coveragela ligne a été exécutéeaucune assertion requise
Branch coveragechaque if/else, ?:, && a pris ses deux cheminsun else implicite manquant n'est jamais compté
Mutation scoretes tests détectent un changement de comportementlent (réexécute la suite par mutant)

Mutation testing est la seule métrique qui mesure la qualité des tests, pas leur présence. Le principe (Stryker pour TS) : l'outil mute ton code source — remplace > par >=, + par -, supprime un appel, inverse un booléen, vide un corps de fonction — relance ta suite, et compte combien de mutants sont tués (au moins un test devient rouge). Un mutant survivant = un changement de comportement qu'aucun test n'attrape = un bug que tu ne verrais pas.

jsonc
// stryker.conf.json — cible le code à fort risque, pas tout le repo (mutation testing est lent)
{
  "packageManager": "pnpm",
  "testRunner": "jest",
  "mutate": ["src/transfers/**/*.ts", "src/kyc/**/*.ts", "!src/**/*.module.ts"],
  "thresholds": { "high": 80, "low": 60, "break": 55 }, // break => exit code non-zéro en CI
  "coverageAnalysis": "perTest" // optimisation : ne relance que les tests qui couvrent le mutant
}

Comment un staff raisonne : il ne court pas après 90% de line coverage sur tout le repo (coût marginal absurde sur les DTO, modules, mappers triviaux). Il fixe un floor modeste de branch coverage (~80%) en gate, et réserve la mutation testing au cœur métier à fort risque (calcul de solde, scoring KYC, moteur de pricing, idempotence) où un bug coûte de l'argent ou de la conformité. Mutation testing sur 100% du repo prendrait des heures ; sur les 3 modules critiques, ~minutes, et il attrape les tests-sans-assertion que la coverage cache. Argument anti-manager à garder en poche : « 100% de coverage avec zéro assertion = 0% de protection ; je préfère 70% de coverage avec un mutation score de 85% sur le code qui touche l'argent. »

Failure mode classique révélé par les mutants : la boundary off-by-one. Tu testes amount > LIMIT avec amount = 1000 et amount = 0 ; Stryker mute > en >= et survit parce qu'aucun test n'exerce amount === LIMIT. Le mutant survivant t'oblige à ajouter le cas-limite — exactement le bug qui pète en prod un vendredi soir.

🧱 Le piège des providers globaux en e2e (pipes, guards, filtres, interceptors)

Symptôme classique : tes specs e2e passent au vert, la prod renvoie des 400/401/500 que tu n'as jamais vus. Cause : main.ts enregistre des cross-cutting concerns (useGlobalPipes, useGlobalGuards, useGlobalFilters, useGlobalInterceptors) que ton Test.createTestingModule({ imports: [AppModule] }) ne rejoue pas. AppModule ne connaît que ce qui est déclaré dans le graphe DI — pas ce que bootstrap() ajoute impérativement après coup.

Deux stratégies, et la deuxième est la bonne :

ts
// ❌ Fragile : tu dupliques main.ts dans le test, ils divergent silencieusement
app.useGlobalPipes(new ValidationPipe({ whitelist: true, transform: true }));

// ✅ Robuste : enregistre les globaux via DI (APP_PIPE/APP_GUARD/APP_FILTER) dans un module.
//    Ils font alors partie du graphe et sont chargés automatiquement par AppModule en test.
@Module({
  providers: [
    { provide: APP_PIPE, useFactory: () => new ValidationPipe({ whitelist: true, transform: true }) },
    { provide: APP_GUARD, useClass: JwtAuthGuard },
    { provide: APP_FILTER, useClass: AllExceptionsFilter },
  ],
})
export class CoreModule {}

Avec APP_*, ton e2e teste exactement la stack de prod (un DTO mal formé renvoie le vrai 400, un guard refuse le vrai 401), et tu n'as plus rien à dupliquer dans le setup de test. C'est la différence entre un e2e qui te rassure faussement et un e2e qui te couvre. Corollaire : pour shunter l'auth dans certains tests, overrideGuard(JwtAuthGuard).useValue({ canActivate: () => true }) — propre, ciblé, réversible par test.

🏋️ Exercices

Escalade : implémenter → rendre production-grade → casser puis réparer. Fais-les dans l'ordre, chacun s'appuie sur le précédent.

1. (Échauffement) State-based vs interaction-based

Objectif : écrire deux versions d'un même test — une qui assertionne l'état de sortie, une qui assertionne l'interaction — et provoquer un faux négatif. Indice/Solution : teste un OrderService.checkout() qui appelle repo.save() puis renvoie l'ordre. Version A : expect(result.status).toBe('paid'). Version B : expect(repo.save).toHaveBeenCalledWith(...). Maintenant refactore le service pour appeler save deux fois (split en deux writes) sans changer le comportement observable → la version B casse, la version A reste verte. Conclusion à écrire : la version B testait l'implémentation. Garde A.

2. Faux clock + idempotence

Objectif : tester un TokenService.issue() qui pose un exp à now + 15min, de façon 100% déterministe, et prouver qu'un cache TTL expire pile au bon moment. Indice/Solution : jest.useFakeTimers({ now: new Date('2026-06-16T10:00:00Z') }), émets le token, jest.advanceTimersByTime(15 * 60_000 - 1) → encore valide, +1ms → expiré. Injecte une Clock ({ now: () => Date }) plutôt que d'appeler Date.now() direct : ça rend le service testable sans fake timers globaux qui contaminent les autres tests.

3. (Production-grade) Integration testcontainers + isolation par worker

Objectif : transformer le test ScheduledTransfer (integration) du fichier pour qu'il tourne en parallèle sur 4 workers Jest sans race condition sur la DB. Indice/Solution : un seul container Postgres, mais CREATE SCHEMA test_${process.env.JEST_WORKER_ID} au boot du worker et SET search_path. Chaque worker voit sa propre table accounts. Vérifie avec jest --maxWorkers=4 que les 3 specs passent en boucle 10×. Alternative : un container par worker (plus lourd, plus simple). Mesure le wall-clock des deux et justifie ton choix.

4. (Casser puis réparer) Le test flaky planté

Objectif : on te donne un test vert 9 fois sur 10. Diagnostiquer la source et le rendre déterministe. Indice/Solution : le test fait const users = await repo.findAll(); expect(users[0].email).toBe('[email protected]') sans ORDER BY, sur une DB où l'ordre d'insertion n'est pas garanti après un vacuum. Fix : ORDER BY created_at dans la requête OU expect(users).toContainEqual(expect.objectContaining({ email: '[email protected]' })). Bonus : ajoute onUnhandledRequest: 'error' et découvre un second appel réseau non mocké qui flakait aussi.

5. (Agent LLM) Tester le tool-use loop et sa terminaison

Objectif : implémenter runAgentLoop (boucle modèle ↔ outils) puis écrire la suite qui prouve : bon outil/bons args, terminaison sur end_turn, borne maxSteps, et AbortController qui coupe la boucle. Indice/Solution : mockResolvedValueOnce pour scénariser tool_use puis end_turn (cf. section 🤖). Le test de terminaison : un mock qui renvoie toujours tool_use doit lever max steps exceeded exactement après maxSteps appels — expect(create).toHaveBeenCalledTimes(maxSteps). N'assertionne jamais le texte généré, seulement le protocole.

6. (Staff, system-level) Le contrat de streaming SSE sous déconnexion

Objectif : prouver qu'un endpoint /chat en text/event-stream (1) flush incrémentalement, (2) émet un event: done terminal, (3) annule l'appel modèle côté serveur quand le client raccroche (req.on('close')AbortController.abort()), pour ne pas continuer à payer des tokens dans le vide. Indice/Solution : fake LlmClient exposant un async generator de deltas (cf. section 🤖). Pour le disconnect, simule res.destroy() côté client après le premier chunk et vérifie que le fake stream a reçu un signal.aborted === true. C'est LE test qui sépare un prototype d'un endpoint AI production-grade : sans lui, un utilisateur qui ferme l'onglet te coûte de l'argent.

7. (Staff, qualité de la suite) Tuer les mutants survivants

Objectif : faire tourner Stryker sur ton module de calcul de solde / scoring, identifier ≥1 mutant survivant, et ajouter le test qui le tue — sans gonfler la line coverage. Indice/Solution : npx stryker run sur src/transfers/**. Tu trouveras typiquement un survivant sur un comparateur de borne (> muté en >=) ou un mutant qui vide le corps d'un guard et n'est attrapé par personne. Écris le cas-limite exact (amount === LIMIT) ou l'assertion manquante. Mesure : la line coverage ne bouge quasiment pas (les lignes tournaient déjà), mais le mutation score monte — preuve concrète que coverage ≠ qualité. Pousse le seuil break à 80 sur ce module et regarde la CY rougir si quelqu'un supprime une assertion.

8. (Casser puis réparer) Le e2e qui ment sur la stack de prod

Objectif : reproduire le faux positif des providers globaux puis le corriger structurellement. Indice/Solution : crée un endpoint POST /users avec un DTO { email: string } validé. Dans main.ts, enregistre app.useGlobalPipes(new ValidationPipe({ whitelist: true })). Écris un e2e qui POST { email: 'x', injected: 'evil' } et attend un 201 propre sans le champ injecté. Le test échoue : sans le pipe rejoué, injected passe (faux positif inverse — le whitelist n'agit pas). Migre le pipe vers { provide: APP_PIPE, useFactory: ... } dans un module ; relance : le test reflète maintenant la prod. Conclusion à écrire : un e2e qui ne charge pas les APP_* ne teste pas ton app, il teste une app imaginaire.

🎤 En entretien

  • « Quelle différence entre un mock et un stub, et lequel privilégies-tu ? » — Stub = renvoie des réponses figées (state-based). Mock = attentes d'interaction pré-programmées qui échouent si non respectées (interaction-based). Je privilégie stub + spy : les mocks stricts couplent le test à l'implémentation et cassent au refactor sans bug réel. Je réserve l'assertion d'interaction aux effets de bord observables uniquement par l'interaction (email envoyé, événement publié).

  • « Ta suite e2e prend 25 min en CI, que fais-tu ? » — D'abord mesurer où va le temps (--json, durations) avant d'optimiser au feeling. Leviers, dans l'ordre : sharding (--shard=i/N) sur N jobs parallèles, modules de test ciblés au lieu de bootstrap AppModule entier, container réutilisé entre suites, isolatedModules. Et structurellement : si j'ai 200 e2e, ma pyramide est inversée — je redescends la logique en unit/integration.

  • « Comment testes-tu un endpoint qui stream une réponse LLM ? » — Je n'assertionne jamais le contenu généré (non déterministe → flaky). Je mocke mon port LlmClient (pas le réseau Anthropic), j'émets un async generator de deltas déterministe, et je teste le contrat de transport : ordre des SSE events, flush incrémental, event: done terminal, fermeture propre, et annulation serveur (AbortController) sur déconnexion client. La qualité de sortie vit dans une suite eval séparée, nightly, non bloquante.

  • « Comment gères-tu un test flaky en CI ? » — Je le traite comme un incident, pas une nuisance : retry aveugle = on perd la confiance dans la suite. Je quarantine (tag @flaky, hors gate bloquant, ticket auto), je trouve la source — temps réel, ordre non garanti, état partagé, async non attendu, port/concurrence, réseau réel — et je la supprime à la racine (fake clock, ORDER BY explicite, onUnhandledRequest: 'error', isolation par worker). Un flaky rate instrumenté par test décide quoi quarantiner.

  • « Mon manager veut 90% de coverage. Tu signes ? » — Non en l'état : la line coverage mesure l'exécution, pas la vérification — un test sans assertion atteint 100% et protège de rien. Je négocie un floor de branch coverage (~80%) en gate sur tout le repo, et je remplace l'objectif vanity par de la mutation testing (Stryker) sur le cœur métier à risque (argent, conformité). Le mutation score est la seule métrique qui prouve que mes tests détectent un changement de comportement. Cible : 70% coverage + 85% mutation score sur le code critique > 90% coverage partout sans assertions.

  • « Tes e2e passent mais la prod renvoie des 400 inattendus. Pourquoi ? » — Quasi toujours les providers globaux : main.ts ajoute useGlobalPipes/Guards/Filters impérativement après bootstrap(), et Test.createTestingModule({ imports: [AppModule] }) ne les rejoue pas — l'e2e teste une app sans ta ValidationPipe. Le fix structurel : enregistrer les cross-cutting concerns via DI (APP_PIPE/APP_GUARD/APP_FILTER) pour qu'ils fassent partie du graphe et soient chargés automatiquement en test. Bonus : pour shunter l'auth en test, overrideGuard().useValue({ canActivate: () => true }).

  • « Unit, integration, e2e : sur quoi mettre l'effort, et comment le justifier à la direction ? » — Pyramide pondérée par le coût d'un bug et le coût du test. Le gros du volume en unit (rapide, déterministe, cible la logique métier), une couche integration sur les frontières I/O réelles (repo Postgres via testcontainers, gateways via msw), et 5–20 e2e sur les flux où un bug coûte vraiment (paiement, auth, signup). Métrique business à présenter : escaped-defect rate et MTTR, pas le nombre de tests. Si j'ai 200 e2e lents et fragiles, ma pyramide est inversée — je redescends la logique d'un cran et je gagne en vitesse et en signal.

🔗 Liens

Bibliothèque tech perso — Achref