Aller au contenu
Réussir mon BTS SIO

Tape une notion, une commande ou le nom d'une épreuve.

Par exemple

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 :

FamilleExemplesPoints forts
Ligne de commandecurl, HTTPieToujours disponible, scriptable, idéal pour reproduire un problème et le partager
Client graphiqueBruno, Postman, Insomnia, HoppscotchConfortable, collections de requêtes, variables d'environnement, tests
Dans l'éditeurFichiers .http (extension REST Client de VS Code, IDE JetBrains), Thunder ClientLes 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 :

OptionRôle
-iAffiche les en-têtes de la réponse avant le corps
-vMode bavard : affiche aussi la requête envoyée (>) et la négociation TLS
-X MÉTHODEChoisit 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)
-sSilencieux : 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:motdepasseAuthentification HTTP Basic
--fail-with-bodyCode de sortie non nul pour un statut 4xx ou 5xx, en affichant quand même le corps : utile dans un script
-LSuit les redirections

Les opérations CRUD sur l'API d'AppliFrais, avec le jeton dans une variable :

bash
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) :

text
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.

bash
# 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.

ClientLicence et modèleÀ savoir en 2026
BrunoLibre (licence MIT), fonctionne hors ligne, sans compteLes 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
PostmanPropriétaire, compte requisLe 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
InsomniaLibre pour le cœur, offre payanteProche de Postman, prise en charge de GraphQL et gRPC
HoppscotchLibre, dans le navigateur ou auto-hébergéRien à installer pour un premier essai
REST Client (VS Code), IDE JetBrainsExtension 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 :

text
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 :

js
// 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 :

js
// 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 :

http
@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 :

FamilleExemple pour POST /v1/fiches/{id}/frais-hors-forfaitRésultat attendu
Cas nominalFrais valide sur sa fiche en cours201, Location, corps conforme au schéma
ValidationMontant négatif, date absente, libellé de 200 caractères, champ inconnu422 (ou 400), corps RFC 9457 qui nomme le champ
FormatJSON mal formé ; Content-Type: text/plain400 ; 415
AuthentificationSans jeton ; jeton expiré ; jeton modifié401 avec WWW-Authenticate
Autorisation sur l'objetFiche d'un autre visiteur404 (ou 403), et rien n'est créé
Autorisation sur la fonctionUn comptable, si le rôle ne le permet pas403
ÉtatFiche clôturée ou validée409
LimitesLibellé à la longueur maximale exacte ; montant maximal ; corps trop grosAccepté à la limite, refusé au-delà (422, 413)
IdempotenceMême requête avec la même Idempotency-KeyUn seul frais créé, même réponse
Paginationlimite=1000 ; page au-delà de la dernièreLimite plafonnée ; liste vide avec 200
CORSOPTIONS depuis l'origine du front ; depuis une autre origineEn-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 :

js
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 500 ou 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 :

  1. Lister les fiches à l'état VA d'octobre 2026, en affichant les en-têtes de la réponse.
  2. Ajouter à la fiche 12 un frais hors forfait « Parking gare » de 12 € le 2026-10-21.
  3. Clôturer la fiche 12.
  4. Supprimer le frais hors forfait 58 et n'afficher que le code de statut.
  5. Vérifier que l'API refuse un appel sans jeton à GET /fiches/12.
Voir la réponseMasquer la réponse de l'exercice 1
bash
# 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 :

text
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."}
  1. Quelle méthode curl a-t-il utilisée ?
  2. Pourquoi l'API refuse-t-elle la requête ?
  3. Corrige la commande de deux façons.
Voir la réponseMasquer la réponse de l'exercice 2
  1. POST : avec -d et sans -X, curl envoie un POST.
  2. Avec -d, curl ajoute Content-Type: application/x-www-form-urlencoded, le type d'un formulaire HTML. L'API n'accepte que du JSON : 415.
  3. Remplacer -d par --json, qui ajoute le bon Content-Type ; ou garder -d et 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
SituationCode attendu
Visiteur propriétaire, fiche CR200, etat vaut CL dans la réponse et à la relecture
Sans jeton401
Jeton expiré ou signature modifiée401
Autre visiteur404 (ou 403), la fiche reste CR
Comptable qui tente de clôturer403 (si la règle le réserve au visiteur)
Fiche déjà CL, VA ou MP409
Fiche inexistante404
Corps {"etat":"XX"}422
Corps {"etat":"VA"} par le visiteur403 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.

js
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 :

js
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é ?

Chapitre terminé !

Fais les 4 exercices avant de passer à la suite : c'est en t'entraînant que tout se fixe.

chapitres
–
de série
–
du cours
–
Chapitre suivant : Webhooks, GraphQL, gRPC et WebSocket : les alternatives à RESTRevoir le sommaire du cours