Tests d'API REST : Guide Stratégique pour une Couverture Complète
Romain Lefebvre
6 mars 2026

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.
