Tester les APIs#

Une API non testée est une API qui casse sans prévenir. Les tests d’API couvrent un spectre plus large que les tests classiques : en plus des tests unitaires et d’intégration habituels, les APIs requièrent des tests de contrat (pour ne pas casser les clients), des tests de schéma (pour valider la conformité aux spécifications), et des tests de charge (pour comprendre le comportement sous stress). Ce chapitre présente ces différents niveaux et leur mise en pratique avec FastAPI et pytest.

Pyramide de tests pour les APIs#

Structure de la pyramide#

La pyramide de tests classique (unitaires en bas, intégration au milieu, end-to-end en haut) s’adapte aux APIs avec des catégories spécifiques.

  • Tests unitaires (base) : handlers isolés, logique métier, sérialiseurs. Rapides, nombreux, pas de réseau ni de base de données.

  • Tests d’intégration (milieu) : endpoint complet avec base de données de test, vrai HTTP via TestClient. Plus lents, moins nombreux.

  • Tests de contrat (milieu-haut) : vérification que le contrat entre consommateur et fournisseur est respecté.

  • Tests de schéma / fuzz (haut) : validation des réponses contre le schéma OpenAPI, génération automatique de cas de test.

  • Tests de charge (hors pyramide) : k6, Locust, métriques de performance.

Ratio recommandé

Viser 70 % de tests unitaires, 20 % de tests d’intégration, 10 % de tests de contrat et de schéma. Les tests de charge s’exécutent dans un pipeline séparé ou à la demande.

import matplotlib.pyplot as plt
import matplotlib.patches as mpatches
import numpy as np
import seaborn as sns

sns.set_theme(style="whitegrid", palette="muted", font_scale=1.1)

fig, ax = plt.subplots(figsize=(10, 8))
ax.set_xlim(0, 10)
ax.set_ylim(0, 9)
ax.axis("off")
ax.set_title("Pyramide de tests pour les APIs", fontsize=13, pad=14)

layers = [
    # (y_base, hauteur, largeur_base, label, sous-label, coût, vitesse, color)
    (0.3, 1.6, 8.0, "Tests unitaires",
     "handlers isolés · mocks · logique métier",
     "Coût : faible", "Vitesse : ms", "#5cb85c"),
    (2.1, 1.6, 6.4, "Tests d'intégration",
     "TestClient · BDD de test · fixtures",
     "Coût : moyen", "Vitesse : secondes", "#5bc0de"),
    (3.9, 1.6, 4.8, "Tests de contrat",
     "Consumer-Driven Contract · Pact",
     "Coût : moyen", "Vitesse : secondes", "#f0ad4e"),
    (5.7, 1.6, 3.2, "Tests de schéma / fuzz",
     "schemathesis · OpenAPI validation",
     "Coût : élevé", "Vitesse : minutes", "#d9534f"),
    (7.5, 1.0, 1.8, "Tests de charge",
     "k6 · Locust · p99",
     "Coût : élevé", "Vitesse : minutes", "#8172b2"),
]

center_x = 5.0
for y, h, w, label, sublabel, cost, speed, color in layers:
    x_left = center_x - w / 2
    rect = mpatches.FancyBboxPatch(
        (x_left, y), w, h,
        boxstyle="round,pad=0.05",
        facecolor=color, edgecolor="white",
        linewidth=1.5, alpha=0.85
    )
    ax.add_patch(rect)
    ax.text(center_x, y + h * 0.55, label,
            ha="center", va="center", fontsize=9.5,
            fontweight="bold", color="white")
    ax.text(center_x, y + h * 0.22, sublabel,
            ha="center", va="center", fontsize=7.5, color="white", alpha=0.9)

    # Annotations latérales
    ax.text(center_x + w / 2 + 0.2, y + h / 2 + 0.1, cost,
            ha="left", va="center", fontsize=7.5, color="#555")
    ax.text(center_x + w / 2 + 0.2, y + h / 2 - 0.2, speed,
            ha="left", va="center", fontsize=7.5, color="#777", style="italic")

ax.annotate("", xy=(center_x - 0.2, 8.6), xytext=(center_x - 0.2, 0.3),
            arrowprops=dict(arrowstyle="->", color="#999", lw=1.2))
ax.text(center_x - 0.6, 8.7, "Lenteur / Coût", fontsize=8, color="#777",
        rotation=90, va="top", ha="center")

plt.show()
_images/468dc78ba2f445c0f0350f63ba40b4af14af3011d9873a39b1707221254d92f1.png

Tests unitaires#

Mock des dépendances#

Les tests unitaires isolent le handler de ses dépendances (base de données, services externes, file de messages). FastAPI supporte l’injection de dépendances, ce qui facilite le mocking.

# test_orders_unit.py
import pytest
from unittest.mock import AsyncMock, MagicMock
from app.handlers.orders import cancel_order, OrderNotFoundError


@pytest.fixture
def mock_db():
    db = MagicMock()
    db.orders.get = AsyncMock()
    db.orders.update = AsyncMock()
    return db


@pytest.mark.asyncio
async def test_cancel_order_pending(mock_db):
    """Une commande pending doit pouvoir être annulée."""
    mock_db.orders.get.return_value = {"id": 17, "status": "pending"}
    mock_db.orders.update.return_value = {"id": 17, "status": "cancelled"}

    result = await cancel_order(order_id=17, db=mock_db)

    assert result["status"] == "cancelled"
    mock_db.orders.update.assert_awaited_once_with(17, {"status": "cancelled"})


@pytest.mark.asyncio
async def test_cancel_order_already_cancelled(mock_db):
    """Une commande déjà annulée doit être retournée sans modification."""
    mock_db.orders.get.return_value = {"id": 17, "status": "cancelled"}

    result = await cancel_order(order_id=17, db=mock_db)

    assert result["status"] == "cancelled"
    mock_db.orders.update.assert_not_awaited()


@pytest.mark.asyncio
async def test_cancel_order_shipped_raises(mock_db):
    """Annuler une commande shipped doit lever une erreur métier."""
    mock_db.orders.get.return_value = {"id": 17, "status": "shipped"}

    with pytest.raises(ValueError, match="impossible d'annuler"):
        await cancel_order(order_id=17, db=mock_db)

Override de dépendances FastAPI#

# conftest.py
import pytest
from fastapi.testclient import TestClient
from unittest.mock import AsyncMock
from app.main import app
from app.dependencies import get_db


@pytest.fixture
def mock_db():
    db = AsyncMock()
    db.orders.get.return_value = {"id": 17, "status": "pending", "total": 89.90}
    return db


@pytest.fixture
def client(mock_db):
    app.dependency_overrides[get_db] = lambda: mock_db
    with TestClient(app) as c:
        yield c
    app.dependency_overrides.clear()


def test_get_order_returns_200(client):
    response = client.get("/orders/17")
    assert response.status_code == 200
    assert response.json()["id"] == 17

Tests d’intégration#

Base de données de test#

Les tests d’intégration utilisent une vraie base de données (souvent SQLite in-memory pour la rapidité, ou PostgreSQL dans Docker en CI).

# conftest.py — base de données SQLite de test
import pytest
import asyncio
from sqlalchemy.ext.asyncio import create_async_engine, AsyncSession
from sqlalchemy.orm import sessionmaker
from app.models import Base
from app.main import app
from app.dependencies import get_db
from fastapi.testclient import TestClient


TEST_DATABASE_URL = "sqlite+aiosqlite:///:memory:"

@pytest.fixture(scope="session")
def event_loop():
    loop = asyncio.get_event_loop_policy().new_event_loop()
    yield loop
    loop.close()


@pytest.fixture(scope="session")
async def engine():
    engine = create_async_engine(TEST_DATABASE_URL, echo=False)
    async with engine.begin() as conn:
        await conn.run_sync(Base.metadata.create_all)
    yield engine
    async with engine.begin() as conn:
        await conn.run_sync(Base.metadata.drop_all)
    await engine.dispose()


@pytest.fixture
async def db_session(engine):
    async_session = sessionmaker(engine, class_=AsyncSession, expire_on_commit=False)
    async with async_session() as session:
        yield session
        await session.rollback()


@pytest.fixture
def client(db_session):
    app.dependency_overrides[get_db] = lambda: db_session
    with TestClient(app) as c:
        yield c
    app.dependency_overrides.clear()

Tests avec fixtures de données#

# test_orders_integration.py
import pytest


@pytest.fixture
async def existing_order(db_session):
    """Crée un order de test et le supprime après le test."""
    order = await db_session.execute(
        "INSERT INTO orders (customer_id, status, total) VALUES (1, 'pending', 89.90) RETURNING id"
    )
    order_id = order.scalar()
    yield order_id
    await db_session.execute(f"DELETE FROM orders WHERE id = {order_id}")


def test_cancel_order_integration(client, existing_order):
    response = client.post(f"/orders/{existing_order}/cancel")
    assert response.status_code == 200
    assert response.json()["status"] == "cancelled"


def test_cancel_order_not_found(client):
    response = client.post("/orders/99999/cancel")
    assert response.status_code == 404
    assert "not found" in response.json()["detail"].lower()


def test_create_order_returns_location_header(client):
    payload = {"customer_id": 1, "items": [{"product_id": 10, "quantity": 2}]}
    response = client.post("/orders", json=payload)
    assert response.status_code == 201
    assert "Location" in response.headers
    assert response.headers["Location"].startswith("/orders/")

Tests de contrat#

Consumer-Driven Contract Testing#

Le Consumer-Driven Contract Testing (CDCT) inverse la responsabilité : c’est le consommateur qui définit le contrat, et le fournisseur doit le satisfaire. Cela évite que le fournisseur casse silencieusement des clients.

Pact — workflow#

Pact est l’outil de référence pour le CDCT.

  1. Le consommateur écrit un test qui décrit ce qu’il attend de l’API.

  2. Pact capture ces attentes dans un fichier de contrat (pact file).

  3. Le fournisseur exécute les vérifications de contrat contre son implémentation réelle.

  4. Le Pact Broker stocke les contrats et les résultats de vérification.

# consumer_test.py — côté client (Python, avec pact-python)
import pytest
from pact import Consumer, Provider

pact = Consumer("OrderService").has_pact_with(Provider("UserService"))


@pytest.fixture(scope="module", autouse=True)
def start_mock_server():
    pact.start_service()
    yield
    pact.stop_service()


def test_get_user_for_order():
    """Le consommateur s'attend à recevoir un user avec id, name, email."""
    (pact
     .given("un utilisateur avec l'id 5 existe")
     .upon_receiving("une requête GET /users/5")
     .with_request("GET", "/users/5")
     .will_respond_with(200, body={
         "id": 5,
         "name": "Alice Dupont",
         "email": "alice@example.com"
     }))

    with pact:
        import requests
        result = requests.get(f"{pact.uri}/users/5")
        assert result.json()["email"] == "alice@example.com"
# provider_verification.py — côté fournisseur
from pact import Verifier

verifier = Verifier(
    provider="UserService",
    provider_base_url="http://localhost:8000"
)

output, _ = verifier.verify_with_broker(
    broker_url="https://pact-broker.example.com",
    provider_states_setup_url="http://localhost:8000/_pact/provider_states",
    publish_verification_results=True,
    provider_version="2.1.0",
)

Contrats en CI/CD

Intégrer la vérification des contrats dans le pipeline CI du fournisseur. Si un changement casse un contrat de consommateur, le pipeline échoue avant le déploiement. Le Pact Broker gère les dépendances entre versions de contrats.

Tests de schéma#

Validation des réponses contre OpenAPI#

Chaque réponse d’API peut être validée contre le schéma OpenAPI correspondant. Cette validation détecte les champs manquants, les types incorrects, les valeurs hors des enums.

# Validation manuelle avec jsonschema
import jsonschema
import json

def load_openapi_schema(path: str, response_schema_ref: str) -> dict:
    with open(path) as f:
        spec = json.load(f)
    # Résolution de la $ref
    ref_path = response_schema_ref.lstrip("#/").split("/")
    schema = spec
    for part in ref_path:
        schema = schema[part]
    return schema

def test_order_response_matches_schema(client):
    response = client.get("/orders/17")
    assert response.status_code == 200

    schema = load_openapi_schema("openapi.json", "#/components/schemas/Order")
    try:
        jsonschema.validate(response.json(), schema)
    except jsonschema.ValidationError as e:
        pytest.fail(f"La réponse ne correspond pas au schéma : {e.message}")

Schemathesis — fuzz testing#

Schemathesis génère automatiquement des cas de test depuis le document OpenAPI et valide les réponses.

# Intégration pytest
import schemathesis

schema = schemathesis.from_path("openapi.yaml", base_url="http://localhost:8000")

@schema.parametrize()
def test_api_fuzzing(case):
    """
    Schemathesis génère des requêtes avec des données valides et invalides
    pour chaque endpoint documenté dans openapi.yaml.
    """
    response = case.call()
    case.validate_response(response)

Tests de charge#

Métriques clés#

Les tests de charge mesurent le comportement sous stress avec plusieurs métriques :

  • Latence p50 / p95 / p99 : médiane, 95e et 99e percentiles. Le p99 représente l’expérience des utilisateurs les plus lents.

  • Throughput : requêtes par seconde (RPS) que l’API peut soutenir.

  • Error rate : pourcentage de réponses en erreur (5xx) sous charge.

  • Time to First Byte (TTFB) : temps avant la première octet de réponse.

k6#

k6 est l’outil de test de charge moderne, scriptable en JavaScript.

# k6_script.js (JavaScript, non-exécutable ici)
import http from "k6/http";
import { check, sleep } from "k6";

export const options = {
  stages: [
    { duration: "30s", target: 50 },   // rampe montante
    { duration: "1m",  target: 50 },   // charge constante
    { duration: "30s", target: 0 },    // rampe descendante
  ],
  thresholds: {
    "http_req_duration": ["p(99)<500"],  // p99 < 500ms
    "http_req_failed":   ["rate<0.01"],  // < 1% d'erreurs
  },
};

export default function () {
  const res = http.get("https://api.example.com/orders");
  check(res, {
    "status is 200":  (r) => r.status === 200,
    "response < 200ms": (r) => r.timings.duration < 200,
  });
  sleep(1);
}

Locust#

Locust est l’alternative Python, avec une interface web de suivi en temps réel.

# locustfile.py
from locust import HttpUser, task, between

class APIUser(HttpUser):
    wait_time = between(1, 3)

    @task(3)
    def list_orders(self):
        self.client.get("/orders?limit=20")

    @task(1)
    def get_single_order(self):
        self.client.get("/orders/17")

    @task(1)
    def create_order(self):
        self.client.post("/orders", json={
            "customer_id": 5,
            "items": [{"product_id": 101, "quantity": 1}]
        })
import numpy as np
import matplotlib.pyplot as plt
import seaborn as sns

sns.set_theme(style="whitegrid", palette="muted", font_scale=1.1)

rng = np.random.default_rng(42)

# Simulation d'une distribution de latences réaliste
n_requests = 5000
base_latency = rng.exponential(scale=50, size=n_requests)
slow_requests = rng.exponential(scale=400, size=int(n_requests * 0.05))
latencies = np.concatenate([base_latency, slow_requests])
latencies = np.clip(latencies, 5, 2000)

p50 = np.percentile(latencies, 50)
p95 = np.percentile(latencies, 95)
p99 = np.percentile(latencies, 99)

fig, ax = plt.subplots(figsize=(10, 5))

ax.hist(latencies, bins=80, color="#4c72b0", alpha=0.75, edgecolor="white", linewidth=0.3)

for p, val, color, label in [
    (p50, p50, "#5cb85c", f"p50 = {p50:.0f} ms"),
    (p95, p95, "#f0ad4e", f"p95 = {p95:.0f} ms"),
    (p99, p99, "#d9534f", f"p99 = {p99:.0f} ms"),
]:
    ax.axvline(val, color=color, linewidth=2, linestyle="--", label=label)

ax.set_xlabel("Latence (ms)")
ax.set_ylabel("Nombre de requêtes")
ax.set_title(f"Distribution de latences simulées — {n_requests} requêtes", fontsize=13, pad=14)
ax.legend()
ax.set_xlim(0, 800)
plt.show()
_images/c222cb5e4bd36868582c2259fd417f96aaed5101a4f08f33dfb92baf1b88af9f.png

Tests de régression#

Snapshot testing#

Le snapshot testing compare la réponse actuelle à une réponse de référence enregistrée. Il détecte les changements non intentionnels.

# test_snapshot.py avec syrupy
import pytest
from syrupy.assertion import SnapshotAssertion

def test_order_response_snapshot(client, snapshot: SnapshotAssertion):
    """
    La réponse doit correspondre au snapshot enregistré.
    Mettre à jour avec : pytest --snapshot-update
    """
    response = client.get("/orders/17")
    assert response.status_code == 200
    assert response.json() == snapshot

Détection de breaking changes#

Les breaking changes dans une API REST incluent :

  • Suppression d’un champ dans une réponse

  • Changement de type d’un champ

  • Nouveau champ requis dans une requête

  • Suppression ou renommage d’un endpoint

  • Changement de code de statut pour un cas existant

oasdiff pour les breaking changes

L’outil oasdiff compare deux documents OpenAPI et liste les breaking changes. Il peut être intégré en CI pour bloquer un déploiement qui casserait des clients.

# Comparaison de deux documents OpenAPI (concept)
oasdiff breaking openapi-v1.yaml openapi-v2.yaml

# Exemple de sorties possibles
# ❌ GET /users/{id} - response 200 - body property 'email' removed
# ❌ POST /orders - request body property 'items' became required
# ⚠  GET /products - response 200 - new optional property 'sku' added

CI/CD pour les APIs#

Pipeline de test#

Un pipeline CI complet pour une API REST comporte plusieurs étapes.

# .github/workflows/api-tests.yml (commenté, non exécuté)
name: API Tests

on: [push, pull_request]

jobs:
  unit-tests:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - name: Tests unitaires
        run: pytest tests/unit/ -v --cov=app --cov-report=xml

  integration-tests:
    runs-on: ubuntu-latest
    services:
      postgres:
        image: postgres:16
        env:
          POSTGRES_DB: testdb
          POSTGRES_PASSWORD: test
    steps:
      - uses: actions/checkout@v4
      - name: Tests d'intégration
        run: pytest tests/integration/ -v

  openapi-validation:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - name: Validation du document OpenAPI avec Spectral
        run: npx @stoplight/spectral-cli lint openapi.yaml

  breaking-change-detection:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
        with: {fetch-depth: 0}
      - name: Détection de breaking changes
        run: |
          git show HEAD~1:openapi.yaml > openapi-prev.yaml
          oasdiff breaking openapi-prev.yaml openapi.yaml --fail-on ERR

  contract-tests:
    runs-on: ubuntu-latest
    needs: [integration-tests]
    steps:
      - uses: actions/checkout@v4
      - name: Vérification des contrats Pact
        run: python -m pytest tests/contract/ -v
        env:
          PACT_BROKER_URL: ${{ secrets.PACT_BROKER_URL }}
import itertools
import json

def generate_test_cases_from_schema(
    operation: dict,
    max_cases: int = 20
) -> list[dict]:
    """
    Génère des cas de test depuis un schéma de paramètres OpenAPI simplifié.
    Retourne une liste de dicts décrivant les requêtes à tester.
    """
    parameters = operation.get("parameters", [])
    cases = []

    # Valeurs de test par type
    test_values = {
        "integer": [1, 0, -1, 999999, None, "not_an_int"],
        "string":  ["valid", "", "a" * 256, None, "'; DROP TABLE--"],
        "boolean": [True, False, None, "true", 1],
    }

    # Cas nominaux : tous les paramètres requis avec valeurs valides
    nominal = {}
    for param in parameters:
        schema_type = param.get("schema", {}).get("type", "string")
        vals = test_values.get(schema_type, ["test"])
        nominal[param["name"]] = vals[0]
    cases.append({"type": "nominal", "params": dict(nominal), "expect_success": True})

    # Cas limites : un paramètre invalide à la fois
    for param in parameters:
        schema_type = param.get("schema", {}).get("type", "string")
        is_required = param.get("required", False)
        vals = test_values.get(schema_type, ["test"])

        for val in vals[1:3]:  # 2 valeurs invalides
            test_case = dict(nominal)
            test_case[param["name"]] = val
            cases.append({
                "type": "invalid_param",
                "param": param["name"],
                "value": val,
                "params": test_case,
                "expect_success": False,
            })

        # Paramètre manquant si requis
        if is_required:
            test_case = {k: v for k, v in nominal.items() if k != param["name"]}
            cases.append({
                "type": "missing_required",
                "param": param["name"],
                "params": test_case,
                "expect_success": False,
            })

    return cases[:max_cases]


# Schéma d'opération exemple
operation = {
    "operationId": "listOrders",
    "parameters": [
        {"name": "status",   "in": "query", "required": False, "schema": {"type": "string"}},
        {"name": "limit",    "in": "query", "required": False, "schema": {"type": "integer"}},
        {"name": "customer_id", "in": "query", "required": True, "schema": {"type": "integer"}},
    ]
}

cases = generate_test_cases_from_schema(operation)

print(f"=== Cas de test générés pour '{operation['operationId']}' ===\n")
print(f"Total : {len(cases)} cas\n")

for i, case in enumerate(cases):
    status = "succès attendu" if case["expect_success"] else "erreur attendue"
    print(f"Cas {i+1:02d} [{case['type']}] — {status}")
    print(f"       params: {json.dumps(case['params'])}")
print()
=== Cas de test générés pour 'listOrders' ===

Total : 8 cas

Cas 01 [nominal] — succès attendu
       params: {"status": "valid", "limit": 1, "customer_id": 1}
Cas 02 [invalid_param] — erreur attendue
       params: {"status": "", "limit": 1, "customer_id": 1}
Cas 03 [invalid_param] — erreur attendue
       params: {"status": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", "limit": 1, "customer_id": 1}
Cas 04 [invalid_param] — erreur attendue
       params: {"status": "valid", "limit": 0, "customer_id": 1}
Cas 05 [invalid_param] — erreur attendue
       params: {"status": "valid", "limit": -1, "customer_id": 1}
Cas 06 [invalid_param] — erreur attendue
       params: {"status": "valid", "limit": 1, "customer_id": 0}
Cas 07 [invalid_param] — erreur attendue
       params: {"status": "valid", "limit": 1, "customer_id": -1}
Cas 08 [missing_required] — erreur attendue
       params: {"status": "valid", "limit": 1}
import numpy as np
import matplotlib.pyplot as plt
import seaborn as sns

sns.set_theme(style="whitegrid", palette="muted", font_scale=1.1)

# Simulation du taux d'erreur sous charge croissante
rng = np.random.default_rng(0)

rps_values = np.arange(10, 510, 10)

def error_rate(rps: np.ndarray) -> np.ndarray:
    """
    Modèle : taux d'erreur quasi-nul jusqu'à un seuil,
    puis augmentation rapide (saturation du pool de connexions BDD).
    """
    threshold = 280
    rate = np.where(
        rps < threshold,
        rng.uniform(0, 0.005, size=rps.shape),
        0.005 + 0.3 * ((rps - threshold) / (500 - threshold)) ** 2
                + rng.uniform(0, 0.02, size=rps.shape)
    )
    return np.clip(rate * 100, 0, 100)


error_rates = error_rate(rps_values)
threshold_rps = 280
sla_limit = 1.0  # SLA : < 1 % d'erreurs

fig, ax = plt.subplots(figsize=(10, 5))

ax.plot(rps_values, error_rates, color="#4c72b0", linewidth=2.5, label="Taux d'erreur (%)")
ax.axhline(sla_limit, color="#d9534f", linewidth=1.5, linestyle="--",
           label=f"SLA : {sla_limit}% d'erreurs max")
ax.axvline(threshold_rps, color="#f0ad4e", linewidth=1.5, linestyle=":",
           label=f"Seuil de dégradation (~{threshold_rps} RPS)")

ax.fill_between(rps_values, error_rates, sla_limit,
                where=(error_rates > sla_limit),
                alpha=0.15, color="#d9534f", label="Zone de violation SLA")

ax.set_xlabel("Charge (requêtes/seconde)")
ax.set_ylabel("Taux d'erreur (%)")
ax.set_title("Taux d'erreur simulé sous charge croissante", fontsize=13, pad=14)
ax.set_ylim(0, 35)
ax.legend(loc="upper left")
plt.show()
_images/df4bcdb52077708ad9bfca801e8ef4c6faf5e96fa852a05c5c54fd83325a8f0e.png

Résumé#

Ce chapitre a couvert l’ensemble de la stratégie de test pour les APIs REST.

La pyramide de tests adapte la stratégie classique aux spécificités des APIs : tests unitaires des handlers isolés (mocks des dépendances), tests d’intégration avec TestClient et base de données de test, tests de contrat pour protéger les consommateurs, et tests de schéma pour valider la conformité à OpenAPI.

Les tests unitaires FastAPI s’appuient sur dependency_overrides pour substituer les dépendances réelles par des mocks. Les tests d’intégration utilisent un TestClient avec une base de données dédiée et des fixtures qui nettoient l’état après chaque test.

Le Consumer-Driven Contract Testing (Pact) protège les intégrations entre services : le consommateur définit ses attentes, le fournisseur les vérifie. Ce mécanisme est particulièrement précieux dans les architectures microservices où les services évoluent indépendamment.

Les tests de charge mesurent les latences p50/p95/p99, le throughput maximal et le point de dégradation. k6 et Locust sont les outils de référence. La détection de breaking changes via oasdiff complète le tableau en garantissant que les évolutions de l’API ne cassent pas silencieusement les clients existants.

Le pipeline CI/CD orchestre ces différentes couches : tests unitaires et d’intégration à chaque push, validation OpenAPI avec Spectral, détection de breaking changes sur la comparaison avec la version précédente, vérification des contrats Pact avant tout déploiement.