Tests d'Intégration

Tester les Microservices : Stratégies et Contract Testing

Romain Lefebvre

Romain Lefebvre

4 avril 2026

Tester les Microservices : Stratégies et Contract Testing

Le défi spécifique des microservices

Dans un monolithe, tester les interactions entre composants est relativement simple — tout tourne dans le même processus. Dans une architecture microservices, chaque service est indépendant, communique via le réseau, et peut évoluer à son propre rythme.

Cela crée une question fondamentale : comment tester que le service A et le service B continuent à bien communiquer quand ils sont déployés indépendamment ?

Les tests E2E qui démarrent l'ensemble du système répondent à cette question, mais ils sont lents, fragiles, et difficiles à maintenir quand le système grossit. Il faut une approche plus intelligente.

La pyramide de tests adaptée aux microservices

La pyramide de tests classique (unitaire > intégration > E2E) se décline ainsi pour les microservices :

        ╔════════════════╗
        ║   E2E (rares)  ║  Tests de parcours critiques uniquement
        ╚════════════════╝
      ╔══════════════════════╗
      ║   Contract Tests     ║  Vérification des contrats inter-services
      ╚══════════════════════╝
    ╔════════════════════════════╗
    ║   Integration (par service)║  Tests internes au service
    ╚════════════════════════════╝
  ╔══════════════════════════════════╗
  ║        Unit Tests                ║  Logique métier pure
  ╚══════════════════════════════════╝

La couche Contract Tests est spécifique aux microservices — c'est là que se résout le problème de la communication inter-services.

Strategy 1 : Tester chaque service en isolation

Chaque microservice doit être testable de façon autonome. Les dépendances externes sont remplacées par des doublures :

// service-commandes/src/services/stock.client.ts
export interface StockClient {
  checkAvailability(productId: string, quantity: number): Promise<boolean>;
  reserveStock(productId: string, quantity: number): Promise<string>; // Retourne un reservationId
}

// service-commandes/src/__tests__/order.service.test.ts
describe('OrderService', () => {
  const mockStockClient: StockClient = {
    checkAvailability: jest.fn(),
    reserveStock: jest.fn(),
  };

  const mockPaymentClient = {
    charge: jest.fn(),
  };

  let service: OrderService;

  beforeEach(() => {
    jest.clearAllMocks();
    service = new OrderService(
      mockOrderRepo,
      mockStockClient,
      mockPaymentClient,
    );
  });

  it('crée la commande quand le stock est disponible', async () => {
    (mockStockClient.checkAvailability as jest.Mock).mockResolvedValue(true);
    (mockStockClient.reserveStock as jest.Mock).mockResolvedValue('res_123');
    (mockPaymentClient.charge as jest.Mock).mockResolvedValue({ id: 'pay_456', status: 'succeeded' });

    const order = await service.createOrder({
      customerId: 'cust_1',
      productId: 'prod_1',
      quantity: 2,
      totalAmount: 4999,
    });

    expect(order.status).toBe('confirmed');
    expect(order.stockReservationId).toBe('res_123');
  });

  it('refuse la commande si le stock est insuffisant', async () => {
    (mockStockClient.checkAvailability as jest.Mock).mockResolvedValue(false);

    await expect(
      service.createOrder({ customerId: 'c', productId: 'p', quantity: 100, totalAmount: 999 })
    ).rejects.toThrow('Stock insuffisant');

    expect(mockStockClient.reserveStock).not.toHaveBeenCalled();
    expect(mockPaymentClient.charge).not.toHaveBeenCalled();
  });
});

Strategy 2 : Contract Testing avec Pact

Le Contract Testing résout le problème fondamental : comment s'assurer que le service A (consommateur) et le service B (fournisseur) ont une API compatible, sans les démarrer ensemble ?

Pact est l'outil standard pour ça. Le principe :

  1. Le consommateur définit ce qu'il attend du fournisseur (le contrat)
  2. Ces attentes sont sauvegardées dans un fichier Pact
  3. Le fournisseur joue les interactions du fichier Pact contre sa vraie implémentation
  4. Si les deux correspondent, le contrat est validé
npm install --save-dev @pact-foundation/pact

Côté consommateur (service-commandes)

// service-commandes/src/__tests__/contract/stock.pact.test.ts
import { Pact } from '@pact-foundation/pact';
import path from 'path';
import { StockHttpClient } from '../../clients/stock.http-client';

const provider = new Pact({
  consumer: 'service-commandes',
  provider: 'service-stock',
  port: 8080,
  log: path.resolve(process.cwd(), 'logs', 'pact.log'),
  dir: path.resolve(process.cwd(), 'pacts'), // Les contrats sont sauvegardés ici
  logLevel: 'warn',
});

describe('Service Stock — Contrat Consommateur', () => {
  beforeAll(() => provider.setup());
  afterAll(() => provider.finalize());

  describe('GET /stock/{productId}/availability', () => {
    beforeEach(() => {
      return provider.addInteraction({
        state: 'produit prod_123 en stock avec 50 unités',
        uponReceiving: 'une requête de disponibilité pour prod_123 avec quantité 5',
        withRequest: {
          method: 'GET',
          path: '/stock/prod_123/availability',
          query: { quantity: '5' },
          headers: { Accept: 'application/json' },
        },
        willRespondWith: {
          status: 200,
          headers: { 'Content-Type': 'application/json' },
          body: {
            available: true,
            currentStock: 50,
            productId: 'prod_123',
          },
        },
      });
    });

    afterEach(() => provider.verify());

    it('retourne la disponibilité du produit', async () => {
      const client = new StockHttpClient('http://localhost:8080');
      const result = await client.checkAvailability('prod_123', 5);

      expect(result).toBe(true);
    });
  });

  describe('POST /stock/{productId}/reserve', () => {
    beforeEach(() => {
      return provider.addInteraction({
        state: 'produit prod_123 en stock avec 50 unités',
        uponReceiving: 'une requête de réservation de 5 unités de prod_123',
        withRequest: {
          method: 'POST',
          path: '/stock/prod_123/reserve',
          headers: {
            'Content-Type': 'application/json',
            Accept: 'application/json',
          },
          body: { quantity: 5 },
        },
        willRespondWith: {
          status: 201,
          headers: { 'Content-Type': 'application/json' },
          body: {
            reservationId: Pact.Matchers.uuid(), // N'importe quel UUID
            expiresAt: Pact.Matchers.iso8601DateTime(), // N'importe quelle date ISO
          },
        },
      });
    });

    afterEach(() => provider.verify());

    it('réserve le stock et retourne un reservationId', async () => {
      const client = new StockHttpClient('http://localhost:8080');
      const reservationId = await client.reserveStock('prod_123', 5);

      expect(reservationId).toBeDefined();
      expect(typeof reservationId).toBe('string');
    });
  });
});

Côté fournisseur (service-stock)

// service-stock/src/__tests__/contract/pact.provider.test.ts
import { Verifier } from '@pact-foundation/pact';
import path from 'path';
import { startTestServer, stopTestServer } from '../helpers/test-server';

describe('Service Stock — Vérification Fournisseur', () => {
  let app: Express;

  beforeAll(async () => {
    app = await startTestServer(3001);
  });

  afterAll(async () => {
    await stopTestServer(app);
  });

  it('valide les contrats consommateurs', () => {
    return new Verifier({
      providerBaseUrl: 'http://localhost:3001',
      pactUrls: [
        path.resolve(
          __dirname,
          '../../../service-commandes/pacts/service-commandes-service-stock.json'
        ),
      ],
      // Préparer l'état décrit dans les contrats
      stateHandlers: {
        'produit prod_123 en stock avec 50 unités': async () => {
          await seedProduct({ id: 'prod_123', stock: 50 });
        },
        'produit prod_123 en rupture de stock': async () => {
          await seedProduct({ id: 'prod_123', stock: 0 });
        },
      },
    }).verifyProvider();
  });
});

Pact Broker — partager les contrats en équipe

En équipe, les fichiers Pact doivent être partagés entre les dépôts. Le Pact Broker est un serveur centralisé pour ça :

# docker-compose.yml pour le Pact Broker local
services:
  postgres:
    image: postgres:16-alpine
    environment:
      POSTGRES_DB: pactbroker
      POSTGRES_USER: pactbroker
      POSTGRES_PASSWORD: pactbroker

  pact-broker:
    image: pactfoundation/pact-broker:latest
    ports:
      - '9292:9292'
    environment:
      PACT_BROKER_DATABASE_URL: 'postgres://pactbroker:pactbroker@postgres/pactbroker'
    depends_on:
      - postgres

Configuration Pact pour publier/récupérer depuis le broker :

// Publication des contrats (dans le pipeline CI du consommateur)
const pact = new Pact({
  consumer: 'service-commandes',
  provider: 'service-stock',
  pactBrokerUrl: 'https://pact-broker.example.com',
  publishVerificationResults: true,
  providerVersion: process.env.GIT_SHA,
  tags: ['main', process.env.BRANCH_NAME],
});

// Vérification (dans le pipeline CI du fournisseur)
new Verifier({
  providerBaseUrl: 'http://localhost:3001',
  pactBrokerUrl: 'https://pact-broker.example.com',
  provider: 'service-stock',
  publishVerificationResults: true,
  providerVersion: process.env.GIT_SHA,
});

Tester la communication asynchrone (messages)

De nombreux microservices communiquent via des queues (RabbitMQ, Kafka). Pact gère aussi ce cas :

// Contrat pour les messages
describe('Service Commandes — Consommateur de messages stock', () => {
  const messagePact = new MessageConsumerPact({
    consumer: 'service-commandes',
    provider: 'service-stock-events',
  });

  it('traite l\'événement StockUpdated', () => {
    return messagePact
      .given('un événement StockUpdated valide')
      .expectsToReceive('un message StockUpdated')
      .withContent({
        eventType: 'StockUpdated',
        productId: Pact.Matchers.string('prod_123'),
        newStock: Pact.Matchers.integer(45),
        timestamp: Pact.Matchers.iso8601DateTime(),
      })
      .withMetadata({
        'content-type': 'application/json',
      })
      .verify(async (message) => {
        // Vérifier que notre handler traite le message correctement
        const handler = new StockUpdatedHandler(mockOrderRepo);
        await handler.handle(JSON.parse(message.contents.toString()));
        
        expect(mockOrderRepo.updateAvailability).toHaveBeenCalledWith(
          'prod_123',
          45
        );
      });
  });
});

Tests d'intégration avec WireMock

Pour les services tiers (APIs externes), WireMock permet de simuler un serveur HTTP complet :

// service-commandes/src/__tests__/integration/payment.test.ts
import WireMock from 'wiremock-captain';

describe('PaymentHttpClient (intégration)', () => {
  let wireMock: WireMock;

  beforeAll(async () => {
    wireMock = new WireMock('http://localhost:8081');
    await wireMock.start();
  });

  afterAll(async () => {
    await wireMock.stop();
  });

  beforeEach(async () => {
    await wireMock.clearAllMappings();
  });

  it('gère un paiement refusé (code 402)', async () => {
    await wireMock.register({
      request: {
        method: 'POST',
        url: '/v1/payment_intents',
      },
      response: {
        status: 402,
        jsonBody: {
          error: {
            code: 'card_declined',
            message: 'Your card was declined.',
          },
        },
      },
    });

    const client = new PaymentHttpClient('http://localhost:8081');

    await expect(
      client.charge({ amount: 9999, currency: 'eur', customerId: 'cust_1' })
    ).rejects.toThrow('card_declined');
  });

  it('gère un timeout avec retry', async () => {
    await wireMock.register({
      request: { method: 'POST', url: '/v1/payment_intents' },
      response: {
        fixedDelayMilliseconds: 5000, // Délai artificiel
        status: 200,
        jsonBody: { id: 'pi_test', status: 'succeeded' },
      },
    });

    const client = new PaymentHttpClient('http://localhost:8081', { timeout: 1000 });

    await expect(
      client.charge({ amount: 100, currency: 'eur', customerId: 'cust_1' })
    ).rejects.toThrow(/timeout/i);
  });
});

Conclusion

Tester des microservices efficacement nécessite une stratégie en couches :

  1. Tests unitaires pour la logique métier pure de chaque service
  2. Tests d'intégration pour vérifier chaque service avec sa vraie infrastructure (DB, cache, queue)
  3. Contract tests pour garantir la compatibilité API entre services sans démarrer tout le système
  4. Tests E2E en nombre limité pour les parcours critiques uniquement

Le contract testing est l'investissement le plus rentable dans une architecture microservices mature. Il permet à chaque équipe de déployer en confiance son service, sachant que les contrats sont vérifiés automatiquement en CI sans coordination manuelle.