Aller au contenu
English

Tests automatisés des points de terminaison

  • Leçon 16
  • 45 min
  • Module 6 : Tester et renforcer le service

Ce que vous saurez faire

Testez tous les chemins de l’API en moins d’une seconde, y compris celui que la collection Postman ne peut pas atteindre : le classificateur est indisponible. Le test montre que l’API renvoie une erreur contrôlée que le client peut traiter, plutôt que de planter.

Avant de commencerCe qu’il faut savoir et avoir sous la mainEnviron 45 minutes

Ce que vous devez savoir

Ce qu’il vous faut sur votre ordinateur

Testé avec Python 3.14.6, FastAPI 0.142.2, pytest 9.1.1 et httpx2 2.13.1 sur macOS.

TestClient envoie des requêtes HTTP directement à votre objet application, en mémoire. Il n’y a ni serveur, ni port, ni réseau. Un test prend donc environ une milliseconde, et les résultats sont toujours les mêmes. Il offre les mêmes méthodes que httpx : client.get(...), client.post(..., json=...). La réponse possède .status_code, .json() et .headers.

from fastapi.testclient import TestClient
from ticket_api.main import app
client = TestClient(app)
response = client.get("/health")
assert response.status_code == 200

Vous l’avez utilisé au module 4 pour les tests du contrat. Maintenant, vous testez le comportement de chaque point de terminaison avec les trois types de cas du cours Python : normal, limite et erreur.

tests/test_classify.py
import pytest
from fastapi.testclient import TestClient
from ticket_api.classifier import ClassifierUnavailable
from ticket_api.main import app
from ticket_api.routes import get_classifier
client = TestClient(app)
def test_health():
response = client.get("/health")
assert response.status_code == 200
assert response.json() == {"status": "ok"}
def test_billing_ticket():
response = client.post(
"/v1/classify",
json={"subject": "Charged twice for March", "body": "Same invoice."},
)
assert response.status_code == 200
assert response.json() == {
"category": "billing",
"priority": 1,
"confidence": 0.9,
"model_version": "keywords-1.0",
}
@pytest.mark.parametrize("subject", ["x", "x" * 120])
def test_subject_length_limits_are_accepted(subject):
assert client.post("/v1/classify", json={"subject": subject}).status_code == 200
@pytest.mark.parametrize(
("payload", "field"),
[
({"subject": ""}, "subject"),
({"subject": "x" * 121}, "subject"),
({"body": "no subject"}, "subject"),
({"subject": "Hi", "colour": "red"}, "colour"),
],
)
def test_invalid_payload_names_the_field(payload, field):
response = client.post("/v1/classify", json=payload)
assert response.status_code == 422
assert response.json()["error"]["code"] == "invalid_request"
assert field in response.json()["error"]["fields"]
def test_malformed_json():
response = client.post(
"/v1/classify",
content='{"subject": "x"',
headers={"Content-Type": "application/json"},
)
assert response.status_code == 422
assert response.json()["error"]["fields"] == ["body"]
Test Type Ce qu’il prouve
test_billing_ticket Normal Tout le corps est exactement correct, pas seulement la catégorie.
test_subject_length_limits_are_accepted Limite Les longueurs de 1 et de 120 caractères sont acceptées : les limites ne sont pas décalées d’un caractère.
test_invalid_payload_names_the_field Erreur Quatre corps invalides, une fonction de test. Chacun reçoit 422 et nomme le bon champ.
test_malformed_json Erreur content= envoie du texte qui n’est pas du JSON valide, ce que json= ne peut pas faire.

Avec deux noms, ("payload", "field"), parametrize fournit deux valeurs au test pour chaque cas. Chaque ligne de la liste représente un cas. La liste se lit donc comme les lignes d’échec de votre tableau du contrat.

Le cas que seul un test peut atteindre : une dépendance défaillante

Section intitulée « Le cas que seul un test peut atteindre : une dépendance défaillante »

Un vrai classificateur peut tomber en panne : un serveur de modèle est indisponible ou manque de mémoire. Que répond alors l’API? Vous ne pouvez pas tester cela avec Postman, car votre classificateur ne tombe jamais en panne. Avec pytest, vous pouvez le remplacer.

FastAPI conserve un dictionnaire, app.dependency_overrides. S’il contient une entrée pour une dépendance, FastAPI appelle le remplacement à sa place. La route demande get_classifier, comme dans Séparer les responsabilités. Un test peut donc lui fournir un classificateur qui échoue toujours.

D’abord, donnez un nom à ce type de panne dans classifier.py :

ticket_api/classifier.py (add before Prediction)
class ClassifierUnavailable(Exception):
"""The classifier cannot answer now. The request may work later."""

Ensuite, le test. Une fixture installe le remplacement avant le test et le retire après, avec yield au milieu. Le test suivant retrouve donc le vrai classificateur :

tests/test_classify.py (add at the end)
class BrokenClassifier:
version = "broken"
def predict(self, text: str):
raise ClassifierUnavailable("the classifier is not available")
@pytest.fixture
def broken_classifier():
app.dependency_overrides[get_classifier] = BrokenClassifier
yield
app.dependency_overrides.clear()
def test_unavailable_classifier_gives_503(broken_classifier):
response = TestClient(app, raise_server_exceptions=False).post(
"/v1/classify", json={"subject": "Refund"}
)
assert response.status_code == 503
assert response.json()["error"]["code"] == "classifier_unavailable"
assert "Traceback" not in response.text

raise_server_exceptions=False fait en sorte que le client de test se comporte comme un vrai client. Il reçoit la réponse du serveur au lieu de voir l’exception dans le test. Amira exécute les tests :

Sortie (abrégée)
> assert response.status_code == 503
E assert 500 == 503
FAILED tests/test_classify.py::test_unavailable_classifier_gives_503 - assert...
========================= 1 failed, 11 passed in 0.20s =========================

Le test a révélé une vraie lacune : l’API répond 500 internal_error (erreur interne). Cela dit au client « le serveur a un bogue », ce qui est faux. La requête est correcte, et la même requête peut fonctionner plus tard. La bonne réponse est 503, vue au module 1. Elle ajoute un gestionnaire dans errors.py, à côté des autres :

ticket_api/errors.py (changes)
from ticket_api.classifier import ClassifierUnavailable
# in add_error_handlers, before the handler for Exception:
@app.exception_handler(ClassifierUnavailable)
async def classifier_unavailable(request: Request, exc: ClassifierUnavailable):
logger.warning("classifier unavailable: %s", exc)
return error_response(
503,
"classifier_unavailable",
"The classifier is not available. Try again later.",
)

Le test réussit. Une réponse 503 fait aussi partie du contrat. Elle la documente donc sur la route, à côté de 422 :

ticket_api/routes.py (in responses=)
503: {
"model": ErrorResponse,
"description": "The classifier is not available.",
},

Le test du contrat échoue, comme prévu : le contrat a changé. Elle lit la différence : une nouvelle réponse 503. Elle juge que ce changement ne brise pas la compatibilité : il documente une nouvelle réponse pour un cas qui existait déjà. Elle exécute python -m ticket_api.export_contract. Maintenant, tout réussit :

Sortie
tests/test_classify.py .......... [ 83%]
tests/test_contract.py .. [100%]
============================== 12 passed in 0.12s ==============================
  1. Créez tests/test_classify.py avec les cas normaux et invalides. Exécutez python -m pytest. Tous les tests réussissent.
  2. Ajoutez ClassifierUnavailable à classifier.py, ainsi que le test du classificateur défaillant. Exécutez les tests. Le nouveau test échoue avec assert 500 == 503.
  3. Ajoutez le gestionnaire à errors.py. Exécutez les tests. Tous réussissent, mais vérifiez que le test du contrat réussit aussi. (C’est le cas : la route ne documente pas encore 503.)
  4. Documentez 503 sur la route. Le test du contrat échoue. Lisez la différence qu’il signale.
  5. Exécutez python -m ticket_api.export_contract, puis exécutez les tests de nouveau. Les 12 tests réussissent.

Ajoutez un test pour chaque cas et prédisez le résultat avant de l’exécuter :

  1. GET /v1/categories renvoie exactement les cinq catégories, dans l’ordre.
  2. Un billet de soutien dont l’objet contient un mot urgent, mais aucun mot de catégorie : {"subject": "I cannot do anything"}. À quelle catégorie et à quelle priorité vous attendez-vous?
Afficher les réponses
def test_categories():
response = client.get("/v1/categories")
assert response.json() == {
"categories": ["billing", "login", "shipping", "account", "other"]
}
def test_urgent_word_without_category():
response = client.post("/v1/classify", json={"subject": "I cannot do anything"})
assert response.json()["category"] == "other"
assert response.json()["priority"] == 3

Le deuxième cas pourrait vous surprendre. Le classificateur renvoie other (autre) avec la priorité 3 dès qu’aucune catégorie ne correspond, avant de chercher les mots urgents. Est-ce le bon comportement? C’est une décision de produit, et le test la consigne maintenant. Si le service de soutien veut donner la priorité 1 aux billets urgents other, inscrivez d’abord cette exigence dans le test.

Erreur courante

Un test modifie un autre test

Symptôme : un test réussit seul (python -m pytest -k billing), mais échoue quand vous les exécutez tous. Ou son résultat dépend de l’ordre. Par exemple, test_billing_ticket reçoit 503.

Cause : un test a modifié un état partagé sans le rétablir. Ici, app.dependency_overrides appartient à l’unique objet app que tous les tests utilisent. Si un test définit un remplacement sans l’effacer, tous les tests suivants reçoivent le classificateur défaillant.

Solution :

  1. Modifiez l’état partagé seulement dans une fixture, puis annulez le changement après yield, comme le fait broken_classifier. Le code après yield s’exécute même si le test échoue.
  2. Ne définissez jamais un remplacement dans le corps d’une fonction de test.
  3. Dans la prochaine leçon, chaque test reçoit sa propre nouvelle application, ce qui élimine la plupart des états partagés.

Vérifiez votre compréhension

Pourquoi le test du classificateur défaillant remplace-t-il le classificateur avec dependency_overrides plutôt que d'arrêter un vrai service de classification?

Afficher la réponse

Il n’y a aucun service de classification à arrêter : le classificateur se trouve dans le même programme. Même avec un vrai service, un test qui dépend de l’arrêt d’un serveur est lent et peu fiable. Le remplacement provoque volontairement la panne, chaque fois, en une milliseconde, et la route ne voit pas la différence. Cela fonctionne parce que la route obtient son classificateur par une dépendance, au lieu de le créer elle-même.

Récapitulatif

  • TestClient(app) envoie des requêtes à l’application en mémoire : c’est rapide, et les résultats sont toujours les mêmes.
  • Testez les cas normaux, les deux côtés de chaque limite, les corps invalides et le JSON mal formé. parametrize transforme les lignes du contrat en cas de test.
  • app.dependency_overrides remplace une dépendance dans un test. Installez le remplacement dans une fixture et effacez-le après yield.
  • Une dépendance indisponible produit 503 avec son propre code, et non 500. Documentez cette réponse et mettez à jour le contrat révisé.

Ensuite, vous regrouperez les paramètres dans un module et ajouterez des journaux (logs) qui relient chaque requête à sa réponse.

Leçon suivante : Configuration et journalisation