Chapitre 1119 minIntermédiaire
Tester une API : curl, Bruno, Postman et HTTPie
Par Josselyn C., prof de BTS SIO. Mis à jour le .
À la fin de ce chapitre, tu sauras
- envoyer toute requête avec curl et lire la réponse ;
- organiser une collection avec Bruno ou Postman ;
- lister les cas de test d'une route, refus compris ;
- écrire un test d'intégration qui prouve un contrôle d'accès.
4 exercices corrigés
Tester une API, c'est lui envoyer des requêtes et vérifier que chaque réponse respecte le contrat : le bon code de statut, les bons en-têtes, un corps de la bonne forme, et les bons refus quand la requête est invalide ou non autorisée. On teste à la main pendant le développement, avec curl ou un client graphique comme Bruno ou Postman, puis on automatise les tests pour qu'ils tournent à chaque modification. Ce chapitre présente ces outils, ce qu'il faut tester, et comment écrire des tests d'intégration.
Pourquoi le navigateur ne suffit pas
Taper une URL dans la barre d'adresse envoie un GET, sans en-tête Authorization, et c'est tout. Pour envoyer un POST avec un corps JSON, un PATCH, un DELETE ou un jeton, il faut un client HTTP. Il en existe trois familles :
| Famille | Exemples | Points forts |
|---|---|---|
| Ligne de commande | curl, HTTPie | Toujours disponible, scriptable, idéal pour reproduire un problème et le partager |
| Client graphique | Bruno, Postman, Insomnia, Hoppscotch | Confortable, collections de requêtes, variables d'environnement, tests |
| Dans l'éditeur | Fichiers .http (extension REST Client de VS Code, IDE JetBrains), Thunder Client | Les requêtes vivent à côté du code, dans Git |
curl, le couteau suisse
curl est installé sur Linux, macOS et Windows 10 et suivants. Les options à connaître :
| Option | Rôle |
|---|---|
-i | Affiche les en-têtes de la réponse avant le corps |
-v | Mode bavard : affiche aussi la requête envoyée (>) et la négociation TLS |
-X MÉTHODE | Choisit la méthode (-X DELETE, -X PATCH) |
-H "Nom: valeur" | Ajoute un en-tête (répétable) |
-d '…' | Envoie un corps (et passe en POST si -X n'est pas précisé) |
--json '…' | Envoie un corps JSON et ajoute Content-Type: application/json et Accept: application/json (curl 7.82 et suivants) |
-s | Silencieux : pas de barre de progression |
-o fichier | Écrit le corps dans un fichier (-o /dev/null pour l'ignorer) |
-w "%{http_code}" | Affiche des informations après la réponse, comme le code de statut |
-u login:motdepasse | Authentification HTTP Basic |
--fail-with-body | Code de sortie non nul pour un statut 4xx ou 5xx, en affichant quand même le corps : utile dans un script |
-L | Suit les redirections |
Les opérations CRUD sur l'API d'AppliFrais, avec le jeton dans une variable :
JETON="eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9…"
API="https://api.gsb.example/v1"
# Lire une fiche, en-têtes compris
curl -i "$API/fiches/12" -H "Authorization: Bearer $JETON"
# Lister avec des filtres (guillemets obligatoires à cause du &)
curl -s "$API/fiches?etat=CL&limite=5" -H "Authorization: Bearer $JETON"
# Créer
curl -i --json '{"mois":"2026-11"}' "$API/fiches" -H "Authorization: Bearer $JETON"
# Modifier une partie
curl -i -X PATCH --json '{"etat":"CL"}' "$API/fiches/12" -H "Authorization: Bearer $JETON"
# Supprimer, en n'affichant que le code de statut
curl -s -o /dev/null -w "%{http_code}\n" -X DELETE "$API/fiches/13" -H "Authorization: Bearer $JETON"
La création répond, contre l'API de démonstration (en-têtes Date et Connection omis) :
HTTP/1.1 201 Created
Location: /v1/fiches/13
Content-Type: application/json; charset=utf-8
{"id":13,"visiteurId":7,"mois":"2026-11","etat":"CR","montantValide":null}
Pour lire un gros document JSON, envoie la sortie dans un outil qui l'indente : jq (curl -s … | jq), ou python3 -m json.tool. jq sait aussi extraire une valeur : curl -s … | jq -r '.donnees[0].mois'.
HTTPie, plus lisible
HTTPie est un client en ligne de commande pensé pour les API : la syntaxe est plus courte et la sortie colorée et indentée. champ=valeur construit un corps JSON (les valeurs sont des chaînes ; champ:=valeur envoie un nombre, un booléen ou un tableau), et Nom:valeur ajoute un en-tête.
# GET avec un en-tête
http GET https://api.gsb.example/v1/fiches/12 "Authorization:Bearer $JETON"
# POST JSON : corps {"date":"2026-10-14","libelle":"Taxi gare","montant":18.4}
http POST https://api.gsb.example/v1/fiches/12/frais-hors-forfait \
"Authorization:Bearer $JETON" date=2026-10-14 libelle="Taxi gare" montant:=18.4
Les clients graphiques : Bruno, Postman et les autres
Un client graphique organise les requêtes en collections, gère des environnements (local, préproduction, production) avec leurs variables, enchaîne des requêtes et exécute des tests sur les réponses.
| Client | Licence et modèle | À savoir en 2026 |
|---|---|---|
| Bruno | Libre (licence MIT), fonctionne hors ligne, sans compte | Les collections sont des fichiers texte dans un dossier : elles se versionnent dans Git avec le code de l'API et se relisent en revue de code |
| Postman | Propriétaire, compte requis | Le plus connu et le plus complet (moniteurs, serveurs simulés, documentation) ; depuis mars 2026, son offre gratuite est limitée à un seul utilisateur, le travail en équipe est payant |
| Insomnia | Libre pour le cœur, offre payante | Proche de Postman, prise en charge de GraphQL et gRPC |
| Hoppscotch | Libre, dans le navigateur ou auto-hébergé | Rien à installer pour un premier essai |
| REST Client (VS Code), IDE JetBrains | Extension ou intégré | Des fichiers .http dans le projet |
Pour un projet de BTS, Bruno est un excellent choix : gratuit, sans compte, et la collection de tests de ton API se trouve dans ton dépôt Git, ce qui la rend facile à montrer au jury. Tous ces outils savent importer un document OpenAPI (chapitre 7) et créer une requête par route.
Les variables d'environnement évitent de recopier l'adresse et le jeton dans chaque requête :
URL : {{baseUrl}}/fiches/12
En-tête : Authorization: Bearer {{jeton}}
Environnement « local » : baseUrl = http://localhost:3000/v1
Environnement « préprod » : baseUrl = https://preprod-api.gsb.example/v1
Un script après la réponse de la requête de connexion enregistre le jeton dans une variable, réutilisée par toutes les autres requêtes :
// Bruno (onglet Script, « post response »)
bru.setEnvVar('jeton', res.getBody().accessToken);
// Postman (onglet Scripts, « post-response »)
pm.environment.set('jeton', pm.response.json().accessToken);
Et des tests vérifient automatiquement chaque réponse :
// Bruno (onglet Tests)
test('crée la fiche avec 201 et Location', function () {
expect(res.getStatus()).to.equal(201);
expect(res.getHeader('location')).to.match(/^\/v1\/fiches\/\d+$/);
expect(res.getBody().etat).to.equal('CR');
});
// Postman (onglet Scripts, « post-response »)
pm.test('crée la fiche avec 201 et Location', () => {
pm.response.to.have.status(201);
pm.expect(pm.response.headers.get('Location')).to.match(/^\/v1\/fiches\/\d+$/);
pm.expect(pm.response.json().etat).to.eql('CR');
});
Une collection entière se lance d'un coup, dans l'interface ou en ligne de commande (bru run pour Bruno, postman collection run ou Newman pour Postman), par exemple dans un pipeline d'intégration continue.
Les fichiers .http
Les fichiers .http décrivent des requêtes en texte brut, presque comme elles circulent sur le réseau. L'extension REST Client de VS Code et les IDE JetBrains affichent un lien « Send Request » au-dessus de chacune :
@baseUrl = http://localhost:3000/v1
@jeton = eyJhbGciOi…
### Lire la fiche 12
GET {{baseUrl}}/fiches/12
Authorization: Bearer {{jeton}}
### Créer la fiche de novembre
POST {{baseUrl}}/fiches
Authorization: Bearer {{jeton}}
Content-Type: application/json
{"mois": "2026-11"}
Que faut-il tester ?
Tester seulement le cas où tout va bien, c'est tester la moitié de l'API. Pour chaque route, passe en revue ces familles de cas :
| Famille | Exemple pour POST /v1/fiches/{id}/frais-hors-forfait | Résultat attendu |
|---|---|---|
| Cas nominal | Frais valide sur sa fiche en cours | 201, Location, corps conforme au schéma |
| Validation | Montant négatif, date absente, libellé de 200 caractères, champ inconnu | 422 (ou 400), corps RFC 9457 qui nomme le champ |
| Format | JSON mal formé ; Content-Type: text/plain | 400 ; 415 |
| Authentification | Sans jeton ; jeton expiré ; jeton modifié | 401 avec WWW-Authenticate |
| Autorisation sur l'objet | Fiche d'un autre visiteur | 404 (ou 403), et rien n'est créé |
| Autorisation sur la fonction | Un comptable, si le rôle ne le permet pas | 403 |
| État | Fiche clôturée ou validée | 409 |
| Limites | Libellé à la longueur maximale exacte ; montant maximal ; corps trop gros | Accepté à la limite, refusé au-delà (422, 413) |
| Idempotence | Même requête avec la même Idempotency-Key | Un seul frais créé, même réponse |
| Pagination | limite=1000 ; page au-delà de la dernière | Limite plafonnée ; liste vide avec 200 |
| CORS | OPTIONS depuis l'origine du front ; depuis une autre origine | En-têtes présents ; absents |
Automatiser : les tests d'intégration
Les tests manuels ne se rejouent pas tout seuls. Un test d'intégration d'API démarre l'application (souvent avec une base de test), lui envoie de vraies requêtes HTTP et vérifie les réponses ; il tourne à chaque modification, en local et dans l'intégration continue. En Node.js, la bibliothèque supertest envoie les requêtes directement à l'application Express, sans ouvrir de port :
import { describe, it, expect } from 'vitest';
import request from 'supertest';
import app from '../src/app.js';
import { jetonPour } from './outils.js'; // fabrique un jeton de test signé
describe('POST /v1/fiches/:id/frais-hors-forfait', () => {
const frais = { date: '2026-10-14', libelle: 'Taxi gare', montant: 18.4 };
it('crée le frais sur la fiche en cours du visiteur', async () => {
const res = await request(app)
.post('/v1/fiches/12/frais-hors-forfait')
.set('Authorization', `Bearer ${jetonPour({ sub: '7', role: 'visiteur' })}`)
.send(frais);
expect(res.status).toBe(201);
expect(res.headers.location).toMatch(/^\/v1\/frais-hors-forfait\/\d+$/);
});
it("refuse la fiche d'un autre visiteur sans rien créer", async () => {
const res = await request(app)
.post('/v1/fiches/12/frais-hors-forfait')
.set('Authorization', `Bearer ${jetonPour({ sub: '9', role: 'visiteur' })}`)
.send(frais);
expect(res.status).toBe(404);
});
it('refuse un montant négatif avec une erreur RFC 9457', async () => {
const res = await request(app)
.post('/v1/fiches/12/frais-hors-forfait')
.set('Authorization', `Bearer ${jetonPour({ sub: '7', role: 'visiteur' })}`)
.send({ ...frais, montant: -5 });
expect(res.status).toBe(422);
expect(res.headers['content-type']).toMatch(/application\/problem\+json/);
expect(res.body.erreurs[0].champ).toBe('montant');
});
});
Le même principe existe partout : WebTestCase de Symfony ou les tests HTTP de Laravel en PHP, WebApplicationFactory en ASP.NET Core, le TestClient de FastAPI en Python. Les tests unitaires, les tests d'intégration et la démarche de test en général sont présentés dans le cours Tests.
Deux compléments utiles :
- les tests de contrat vérifient que les réponses respectent le document OpenAPI : des outils comme Schemathesis génèrent même des centaines de requêtes inattendues à partir du document et signalent les réponses
500ou non conformes ; - les tests de charge mesurent le comportement sous la pression (temps de réponse, erreurs) avec des outils comme k6 ou JMeter. Lance-les sur un environnement dédié, jamais sur la production d'un tiers.
Exercices
Exercice 1 sur 4 Écrire les commandes curl Réussi À revoir
Écris la commande curl pour chaque opération sur https://api.gsb.example/v1, avec le jeton dans la variable $JETON :
- Lister les fiches à l'état
VAd'octobre 2026, en affichant les en-têtes de la réponse. - Ajouter à la fiche 12 un frais hors forfait « Parking gare » de 12 € le 2026-10-21.
- Clôturer la fiche 12.
- Supprimer le frais hors forfait 58 et n'afficher que le code de statut.
- Vérifier que l'API refuse un appel sans jeton à
GET /fiches/12.
Voir la réponseMasquer la réponse de l'exercice 1
# 1
curl -i "https://api.gsb.example/v1/fiches?etat=VA&mois=2026-10" -H "Authorization: Bearer $JETON"
# 2
curl -i --json '{"date":"2026-10-21","libelle":"Parking gare","montant":12}' \
"https://api.gsb.example/v1/fiches/12/frais-hors-forfait" -H "Authorization: Bearer $JETON"
# 3
curl -i -X PATCH --json '{"etat":"CL"}' "https://api.gsb.example/v1/fiches/12" \
-H "Authorization: Bearer $JETON"
# 4
curl -s -o /dev/null -w "%{http_code}\n" -X DELETE \
"https://api.gsb.example/v1/frais-hors-forfait/58" -H "Authorization: Bearer $JETON"
# 5 : on attend 401 et un en-tête WWW-Authenticate
curl -i "https://api.gsb.example/v1/fiches/12"
Les guillemets autour de l'URL du 1 sont indispensables : sans eux, le shell interprète & comme « lancer en arrière-plan » et coupe l'URL.
Tu avais trouvé ?
Exercice 2 sur 4 Interpréter une sortie de curl Réussi À revoir
Un camarade lance curl -i -d '{"mois":"2026-11"}' https://api.gsb.example/v1/fiches -H "Authorization: Bearer $JETON" et obtient :
HTTP/1.1 415 Unsupported Media Type
Content-Type: application/problem+json
{"type":"https://api.gsb.example/problemes/format","title":"Format non pris en charge","status":415,"detail":"Le corps doit être envoyé en application/json."}
- Quelle méthode curl a-t-il utilisée ?
- Pourquoi l'API refuse-t-elle la requête ?
- Corrige la commande de deux façons.
Voir la réponseMasquer la réponse de l'exercice 2
POST: avec-det sans-X, curl envoie unPOST.- Avec
-d, curl ajouteContent-Type: application/x-www-form-urlencoded, le type d'un formulaire HTML. L'API n'accepte que du JSON :415. - Remplacer
-dpar--json, qui ajoute le bonContent-Type; ou garder-det ajouter-H "Content-Type: application/json".
Tu avais trouvé ?
Exercice 3 sur 4 Lister les cas de test Réussi À revoir
Rédige la liste des cas de test (requête, situation, code attendu) de la route PATCH /v1/fiches/{id} qui clôture une fiche ({"etat":"CL"}), réservée au visiteur propriétaire d'une fiche à l'état CR.
Voir la réponseMasquer la réponse de l'exercice 3
| Situation | Code attendu |
|---|---|
Visiteur propriétaire, fiche CR | 200, etat vaut CL dans la réponse et à la relecture |
| Sans jeton | 401 |
| Jeton expiré ou signature modifiée | 401 |
| Autre visiteur | 404 (ou 403), la fiche reste CR |
| Comptable qui tente de clôturer | 403 (si la règle le réserve au visiteur) |
Fiche déjà CL, VA ou MP | 409 |
| Fiche inexistante | 404 |
Corps {"etat":"XX"} | 422 |
Corps {"etat":"VA"} par le visiteur | 403 ou 409 selon la règle choisie, et la fiche ne change pas |
| JSON mal formé | 400 |
Corps avec un champ en plus ("montantValide": 9999) | 422 (liste blanche), et montantValide ne change pas |
Pour chaque refus, on vérifie aussi que la fiche n'a pas été modifiée, en la relisant.
Tu avais trouvé ?
Exercice 4 sur 4 Un test qui ne teste rien Réussi À revoir
Ce test passe toujours au vert. Pourquoi ? Corrige-le.
it('refuse la fiche d\'un autre visiteur', async () => {
const res = await request(app)
.get('/v1/fiches/12')
.set('Authorization', `Bearer ${jetonPour({ sub: '9', role: 'visiteur' })}`);
expect(res.status).not.toBe(200);
});
Voir la réponseMasquer la réponse de l'exercice 4
not.toBe(200) accepte n'importe quel autre code : un 500 dû à un bogue, un 401 parce que la fonction jetonPour est mal configurée, un 404 parce que la fiche 12 n'existe pas dans la base de test. Le test passe sans prouver que le contrôle d'accès fonctionne. Il faut attendre le code exact, et s'assurer que la fiche existe bien pour son propriétaire :
it("refuse la fiche d'un autre visiteur", async () => {
const proprietaire = await request(app)
.get('/v1/fiches/12')
.set('Authorization', `Bearer ${jetonPour({ sub: '7', role: 'visiteur' })}`);
expect(proprietaire.status).toBe(200); // la fiche existe et le jeton de test fonctionne
const autre = await request(app)
.get('/v1/fiches/12')
.set('Authorization', `Bearer ${jetonPour({ sub: '9', role: 'visiteur' })}`);
expect(autre.status).toBe(404);
});
Tu avais trouvé ?