Tests d'Intégration

Tests d'API REST : Guide Stratégique pour une Couverture Complète

Romain Lefebvre

Romain Lefebvre

6 mars 2026

Tests d'API REST : Guide Stratégique pour une Couverture Complète

Les API REST constituent l'épine dorsale de la plupart des applications modernes. Qu'il s'agisse de microservices communiquant entre eux, d'une application mobile consommant un backend, ou d'intégrations tierces, la fiabilité de vos API est critique. Pourtant, tester une API REST de manière complète va bien au-delà du simple envoi de requêtes avec un statut 200 en retour.

Ce guide explore les différentes couches de tests applicables à une API REST, les outils disponibles, les stratégies de test par contrat, et l'intégration de ces tests dans un pipeline d'intégration continue.

Pourquoi les tests d'API sont essentiels

Le coût d'un bug en production

Un bug détecté au niveau de l'API coûte exponentiellement plus cher qu'un bug détecté en test unitaire. Une API défaillante peut impacter simultanément l'application web, l'application mobile, les partenaires et les clients API. Les tests d'API se situent au niveau idéal de la pyramide des tests : plus réalistes que les tests unitaires, plus rapides et stables que les tests E2E.

La pyramide des tests appliquée aux API

La stratégie de test d'une API REST s'organise en couches complémentaires :

Couche Type de test Portée Vitesse
Unitaire Logique métier isolée Fonctions, services Très rapide
Intégration Composants connectés Routes + DB + services Rapide
Contrat Interface entre services Schéma requête/réponse Rapide
E2E Flux complet API déployée Lent
Performance Charge et latence API sous stress Variable

Pour comprendre les fondamentaux des tests d'intégration, consultez notre article sur les bonnes pratiques des tests d'intégration.

Tests unitaires de la logique API

Avant de tester l'API elle-même, assurez-vous que la logique métier sous-jacente est solidement couverte par des tests unitaires. Les contrôleurs (ou handlers) doivent être aussi fins que possible, déléguant le travail à des services testables indépendamment.

// service/order.service.js
export function calculateOrderTotal(items, discountCode) {
  const subtotal = items.reduce((sum, item) => sum + item.price * item.quantity, 0);
  const discount = resolveDiscount(discountCode, subtotal);
  const tax = (subtotal - discount) * 0.20;
  return {
    subtotal,
    discount,
    tax,
    total: subtotal - discount + tax
  };
}
// __tests__/order.service.test.js
describe('calculateOrderTotal', () => {
  it('doit calculer le total sans remise', () => {
    const items = [{ price: 50, quantity: 2 }];
    const result = calculateOrderTotal(items, null);
    expect(result.subtotal).toBe(100);
    expect(result.discount).toBe(0);
    expect(result.total).toBe(120); // 100 + 20% TVA
  });

  it('doit appliquer le code de réduction', () => {
    const items = [{ price: 100, quantity: 1 }];
    const result = calculateOrderTotal(items, 'PROMO20');
    expect(result.discount).toBe(20);
    expect(result.total).toBe(96); // (100-20) + 20% TVA
  });

  it('doit rejeter les quantités négatives', () => {
    const items = [{ price: 50, quantity: -1 }];
    expect(() => calculateOrderTotal(items, null)).toThrow('Quantité invalide');
  });
});

Pour approfondir les techniques de tests unitaires, consultez notre guide des tests unitaires et notre article sur les mocks et stubs.

Tests d'intégration HTTP

Supertest pour Node.js / Express

Supertest permet de tester vos routes Express sans démarrer de serveur HTTP réel :

import request from 'supertest';
import app from '../src/app.js';

describe('POST /api/orders', () => {
  it('doit créer une commande valide', async () => {
    const response = await request(app)
      .post('/api/orders')
      .send({
        items: [{ productId: 'abc123', quantity: 2 }],
        shippingAddress: {
          street: '12 rue de la Paix',
          city: 'Paris',
          zipCode: '75002'
        }
      })
      .expect('Content-Type', /json/)
      .expect(201);

    expect(response.body).toMatchObject({
      id: expect.any(String),
      status: 'created',
      items: expect.arrayContaining([
        expect.objectContaining({ productId: 'abc123', quantity: 2 })
      ])
    });
  });

  it('doit rejeter une commande sans articles', async () => {
    const response = await request(app)
      .post('/api/orders')
      .send({ items: [] })
      .expect(400);

    expect(response.body.error).toContain('au moins un article');
  });

  it('doit exiger l\'authentification', async () => {
    await request(app)
      .post('/api/orders')
      .send({ items: [{ productId: 'abc123', quantity: 1 }] })
      .expect(401);
  });
});

HttpClient pour les projets Java/Spring

Spring Boot fournit MockMvc pour les tests d'intégration et TestRestTemplate pour les tests plus réalistes :

@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
class OrderControllerTest {

    @Autowired
    private TestRestTemplate restTemplate;

    @Test
    void shouldCreateOrder() {
        OrderRequest request = new OrderRequest(
            List.of(new OrderItem("abc123", 2)),
            new Address("12 rue de la Paix", "Paris", "75002")
        );

        ResponseEntity<OrderResponse> response = restTemplate
            .postForEntity("/api/orders", request, OrderResponse.class);

        assertThat(response.getStatusCode()).isEqualTo(HttpStatus.CREATED);
        assertThat(response.getBody().getStatus()).isEqualTo("created");
    }
}

Pytest pour Python/FastAPI

from fastapi.testclient import TestClient
from app.main import app

client = TestClient(app)

def test_create_order():
    response = client.post("/api/orders", json={
        "items": [{"product_id": "abc123", "quantity": 2}],
        "shipping_address": {
            "street": "12 rue de la Paix",
            "city": "Paris",
            "zip_code": "75002"
        }
    })
    assert response.status_code == 201
    data = response.json()
    assert data["status"] == "created"
    assert len(data["items"]) == 1

Tester tous les aspects d'une réponse API

Un test d'API complet ne vérifie pas seulement le corps de la réponse. Voici les éléments à contrôler systématiquement :

Codes de statut HTTP

Chaque endpoint doit être testé avec les codes de statut attendus :

Scénario Code attendu Quand vérifier
Création réussie 201 Created POST avec données valides
Lecture réussie 200 OK GET avec ID existant
Ressource introuvable 404 Not Found GET avec ID inexistant
Données invalides 400 Bad Request POST/PUT avec validation échouée
Non authentifié 401 Unauthorized Requête sans token
Non autorisé 403 Forbidden Requête avec permissions insuffisantes
Conflit 409 Conflict Création de doublon
Suppression réussie 204 No Content DELETE avec ID existant

En-têtes de réponse

it('doit retourner les en-têtes de pagination', async () => {
  const response = await request(app)
    .get('/api/products?page=2&limit=10')
    .expect(200);

  expect(response.headers['x-total-count']).toBeDefined();
  expect(response.headers['x-page']).toBe('2');
  expect(response.headers['x-per-page']).toBe('10');
  expect(response.headers['link']).toContain('rel="next"');
});

Validation du schéma de réponse

Utilisez des validateurs JSON Schema pour garantir la structure de vos réponses :

import Ajv from 'ajv';

const ajv = new Ajv();
const orderSchema = {
  type: 'object',
  required: ['id', 'status', 'items', 'total', 'createdAt'],
  properties: {
    id: { type: 'string', format: 'uuid' },
    status: { type: 'string', enum: ['created', 'paid', 'shipped', 'delivered'] },
    items: {
      type: 'array',
      minItems: 1,
      items: {
        type: 'object',
        required: ['productId', 'quantity', 'price'],
        properties: {
          productId: { type: 'string' },
          quantity: { type: 'integer', minimum: 1 },
          price: { type: 'number', minimum: 0 }
        }
      }
    },
    total: { type: 'number', minimum: 0 },
    createdAt: { type: 'string', format: 'date-time' }
  },
  additionalProperties: false
};

it('doit respecter le schéma de réponse', async () => {
  const response = await request(app)
    .post('/api/orders')
    .send(validOrderPayload)
    .expect(201);

  const validate = ajv.compile(orderSchema);
  const isValid = validate(response.body);
  expect(isValid).toBe(true);
});

Temps de réponse

it('doit répondre en moins de 500ms', async () => {
  const start = Date.now();
  await request(app).get('/api/products').expect(200);
  const duration = Date.now() - start;
  expect(duration).toBeLessThan(500);
});

Tests de contrat (Contract Testing)

Le problème des intégrations fragiles

Quand plusieurs équipes travaillent sur des services qui communiquent via API, les changements d'interface peuvent casser les consommateurs sans que personne ne s'en rende compte. Les tests de contrat résolvent ce problème.

Pact : le standard du contract testing

Pact permet au consommateur de définir ses attentes (le contrat) et au fournisseur de vérifier qu'il les respecte :

Côté consommateur :

import { PactV3 } from '@pact-foundation/pact';

const provider = new PactV3({
  consumer: 'FrontendApp',
  provider: 'OrderService'
});

describe('Order API - Consumer', () => {
  it('doit retourner une commande par ID', async () => {
    await provider
      .given('une commande abc123 existe')
      .uponReceiving('requête GET /api/orders/abc123')
      .withRequest({ method: 'GET', path: '/api/orders/abc123' })
      .willRespondWith({
        status: 200,
        body: {
          id: 'abc123',
          status: 'created',
          total: 120.00
        }
      });

    await provider.executeTest(async (mockServer) => {
      const response = await fetch(`${mockServer.url}/api/orders/abc123`);
      const order = await response.json();
      expect(order.id).toBe('abc123');
    });
  });
});

Côté fournisseur :

const { Verifier } = require('@pact-foundation/pact');

describe('Order API - Provider', () => {
  it('doit respecter le contrat du consommateur', async () => {
    await new Verifier({
      providerBaseUrl: 'http://localhost:3000',
      pactUrls: ['./pacts/FrontendApp-OrderService.json'],
      stateHandlers: {
        'une commande abc123 existe': async () => {
          await seedDatabase({ id: 'abc123', status: 'created', total: 120 });
        }
      }
    }).verifyProvider();
  });
});

Avantages du contract testing

  • Détecte les incompatibilités avant le déploiement
  • Permet aux équipes de travailler indépendamment
  • Plus rapide et fiable que les tests E2E inter-services
  • Le Pact Broker centralise tous les contrats

Gestion des données de test

Fixtures et seed data

Chaque test doit partir d'un état connu. Utilisez des fixtures pour peupler la base de données :

// fixtures/orders.js
export const sampleOrders = [
  {
    id: 'order-001',
    userId: 'user-001',
    items: [{ productId: 'prod-001', quantity: 2, price: 29.99 }],
    status: 'created',
    total: 71.98
  },
  {
    id: 'order-002',
    userId: 'user-002',
    items: [{ productId: 'prod-002', quantity: 1, price: 149.99 }],
    status: 'shipped',
    total: 179.99
  }
];

// helpers/database.js
export async function seedOrders() {
  await Order.deleteMany({});
  await Order.insertMany(sampleOrders);
}

Isolation des tests

Chaque test doit être indépendant. Utilisez des hooks beforeEach pour réinitialiser l'état :

beforeEach(async () => {
  await seedOrders();
});

afterAll(async () => {
  await mongoose.connection.close();
});

Testcontainers pour les dépendances

Plutôt que de mocker la base de données, utilisez Testcontainers pour lancer une vraie instance en conteneur Docker :

import { PostgreSqlContainer } from '@testcontainers/postgresql';

let container;

beforeAll(async () => {
  container = await new PostgreSqlContainer()
    .withDatabase('testdb')
    .start();

  process.env.DATABASE_URL = container.getConnectionUri();
  await runMigrations();
});

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

Cette approche offre des tests réalistes sans dépendance à un environnement partagé.

Tests d'authentification et d'autorisation

Tester les différents rôles

describe('Autorisation des endpoints', () => {
  const adminToken = generateToken({ role: 'admin' });
  const userToken = generateToken({ role: 'user' });
  const expiredToken = generateToken({ role: 'user', exp: Math.floor(Date.now() / 1000) - 3600 });

  it('doit permettre à un admin de supprimer un utilisateur', async () => {
    await request(app)
      .delete('/api/users/user-002')
      .set('Authorization', `Bearer ${adminToken}`)
      .expect(204);
  });

  it('doit interdire à un utilisateur de supprimer un autre utilisateur', async () => {
    await request(app)
      .delete('/api/users/user-002')
      .set('Authorization', `Bearer ${userToken}`)
      .expect(403);
  });

  it('doit rejeter un token expiré', async () => {
    await request(app)
      .get('/api/users/me')
      .set('Authorization', `Bearer ${expiredToken}`)
      .expect(401);
  });

  it('doit rejeter une requête sans token', async () => {
    await request(app)
      .get('/api/users/me')
      .expect(401);
  });
});

Tester les limites de débit (Rate Limiting)

it('doit appliquer le rate limiting après 100 requêtes', async () => {
  for (let i = 0; i < 100; i++) {
    await request(app).get('/api/products').expect(200);
  }

  await request(app)
    .get('/api/products')
    .expect(429)
    .expect((res) => {
      expect(res.headers['retry-after']).toBeDefined();
    });
});

Tests de performance API

Au-delà des tests fonctionnels, vérifiez que votre API tient la charge. Pour une approche complète des tests de performance, consultez notre article sur les tests de performance.

k6 pour les tests de charge

// load-test.js (k6)
import http from 'k6/http';
import { check, sleep } from 'k6';

export const options = {
  stages: [
    { duration: '30s', target: 50 },   // montée progressive
    { duration: '1m', target: 50 },     // plateau
    { duration: '30s', target: 200 },   // pic de charge
    { duration: '1m', target: 200 },    // maintien du pic
    { duration: '30s', target: 0 },     // descente
  ],
  thresholds: {
    http_req_duration: ['p(95)<500'],   // 95% des requêtes < 500ms
    http_req_failed: ['rate<0.01'],     // moins de 1% d'erreurs
  },
};

export default function () {
  const res = http.get('https://api.example.com/products');
  check(res, {
    'statut 200': (r) => r.status === 200,
    'temps de réponse < 500ms': (r) => r.timings.duration < 500,
    'corps non vide': (r) => r.body.length > 0,
  });
  sleep(1);
}

Artillery pour les scénarios complexes

# artillery-config.yml
config:
  target: "https://api.example.com"
  phases:
    - duration: 60
      arrivalRate: 10
      name: "Charge normale"
    - duration: 60
      arrivalRate: 50
      name: "Pic de trafic"

scenarios:
  - name: "Parcours utilisateur complet"
    flow:
      - post:
          url: "/api/auth/login"
          json:
            email: "[email protected]"
            password: "secret"
          capture:
            - json: "$.token"
              as: "authToken"
      - get:
          url: "/api/products"
          headers:
            Authorization: "Bearer {{ authToken }}"
          expect:
            - statusCode: 200
      - post:
          url: "/api/orders"
          headers:
            Authorization: "Bearer {{ authToken }}"
          json:
            items:
              - productId: "prod-001"
                quantity: 1
          expect:
            - statusCode: 201

Automatisation dans le pipeline CI/CD

Organisation des tests par couche

Structurez vos tests pour pouvoir les exécuter séparément :

{
  "scripts": {
    "test:unit": "vitest run tests/unit/",
    "test:integration": "vitest run tests/integration/",
    "test:contract": "vitest run tests/contract/",
    "test:e2e": "vitest run tests/e2e/",
    "test:performance": "k6 run tests/load/load-test.js",
    "test:all": "vitest run && npm run test:performance"
  }
}

Pipeline multi-étapes

# .github/workflows/api-tests.yml
jobs:
  unit-tests:
    runs-on: ubuntu-latest
    steps:
      - run: npm run test:unit

  integration-tests:
    runs-on: ubuntu-latest
    needs: unit-tests
    services:
      postgres:
        image: postgres:16
        env:
          POSTGRES_DB: testdb
          POSTGRES_PASSWORD: test
    steps:
      - run: npm run test:integration

  contract-tests:
    runs-on: ubuntu-latest
    needs: unit-tests
    steps:
      - run: npm run test:contract

  performance-tests:
    runs-on: ubuntu-latest
    needs: integration-tests
    if: github.ref == 'refs/heads/main'
    steps:
      - run: npm run test:performance

Les tests unitaires tournent en premier (rapides, feedback immédiat). Les tests d'intégration et de contrat suivent en parallèle. Les tests de performance ne s'exécutent que sur la branche principale pour éviter les faux positifs liés à l'environnement CI. Pour bien configurer votre pipeline, consultez notre guide sur l'intégration CI/CD et les tests.

Bonnes pratiques récapitulatives

Nommer explicitement les scénarios

Chaque test doit décrire le scénario qu'il vérifie :

// Mauvais
it('test POST /orders', () => { ... });

// Bon
it('doit retourner 400 quand le panier est vide', () => { ... });
it('doit créer la commande et retourner le numéro de suivi', () => { ... });

Tester les cas limites

Ne testez pas uniquement le chemin nominal. Les cas limites les plus fréquents :

  • Corps de requête vide ou malformé
  • Champs avec des valeurs extrêmes (chaînes très longues, nombres négatifs)
  • Caractères spéciaux et injections (SQL, XSS)
  • Requêtes concurrentes sur la même ressource
  • Pagination avec des paramètres invalides (page 0, limit -1)
  • Encodages spéciaux dans les URL

Documenter par les tests

Des tests bien écrits servent de documentation vivante pour votre API. Ils montrent les requêtes attendues, les réponses possibles et les cas d'erreur. Combinés avec un outil comme Swagger, ils offrent une référence complète et toujours à jour.

Conclusion

Tester une API REST efficacement demande une stratégie multicouche. Les tests unitaires protègent la logique métier, les tests d'intégration vérifient le câblage HTTP, les tests de contrat sécurisent les interfaces entre services, et les tests de performance garantissent la tenue en charge.

Les points clés à retenir :

  • Structurez vos tests en couches distinctes avec des commandes séparées
  • Automatisez l'exécution dans votre pipeline CI/CD
  • Testez tous les aspects : statut, corps, en-têtes, temps de réponse
  • Isolez chaque test avec des fixtures et des bases de données dédiées
  • Adoptez le contract testing dès que plusieurs services communiquent
  • Mesurez les performances régulièrement, pas seulement avant la mise en production

Une API bien testée est une API dans laquelle les développeurs et les consommateurs peuvent avoir confiance. Investir dans ces tests dès le début du projet coûte bien moins cher que de corriger des bugs en production.