Tester les Microservices : Stratégies et Contract Testing
Romain Lefebvre
4 avril 2026

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 :
- Le consommateur définit ce qu'il attend du fournisseur (le contrat)
- Ces attentes sont sauvegardées dans un fichier Pact
- Le fournisseur joue les interactions du fichier Pact contre sa vraie implémentation
- 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 :
- Tests unitaires pour la logique métier pure de chaque service
- Tests d'intégration pour vérifier chaque service avec sa vraie infrastructure (DB, cache, queue)
- Contract tests pour garantir la compatibilité API entre services sans démarrer tout le système
- 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.
