Tests automatisés des points de terminaison
- Leçon 16
- 45 min
- Module 6 : Tester et renforcer le service
Leçon 16 sur 21 · Créer et déployer votre première APITests automatisés des points de terminaison
1. Comprendre la requête Web
2. Créer un petit service FastAPI
3. Concevoir un contrat utilisable
4. Documenter l'API avec OpenAPI et Swagger UI
5. Explorer et tester avec Postman
6. Tester et renforcer le service
7. Déployer et vérifier
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 main
Ce que vous devez savoir
- Les fixtures (dispositifs de préparation des tests) et
parametrize, présentés dans Des tests ciblés : tester le contrat : cas normaux, limites et erreurs. - Comment la route obtient son classificateur, présenté dans Séparer les responsabilités : dépendances : fournir son classificateur à la route.
- Les gestionnaires d’exceptions, présentés dans Validation et réponses d’erreur : gestionnaires d’exceptions.
Ce qu’il vous faut sur votre ordinateur
- pytest et httpx2 dans l’environnement du projet, comme dans Garder la documentation exacte : installer les outils de test.
ticket_api/export_contract.py,contract/openapi.jsonettests/test_contract.py, présentés dans Garder la documentation exacte : exemple détaillé : le contrat révisé.python -m pytestréussit. Si le test du contrat échoue, lisez la différence et exportez de nouveau, comme dans Détecter un changement accidentel.- Si votre projet manque ou ne fonctionne plus, copiez l’instantané
end-of-m05, le projet à la fin du module 5. Utiliser un instantané décrit les étapes.
Testé avec Python 3.14.6, FastAPI 0.142.2, pytest 9.1.1 et httpx2 2.13.1 sur macOS.
TestClient : des requêtes sans serveur
Section intitulée « TestClient : des requêtes sans serveur »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 == 200Vous 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.
Exemple détaillé : les cas normaux et invalides
Section intitulée « Exemple détaillé : les cas normaux et invalides »import pytestfrom fastapi.testclient import TestClient
from ticket_api.classifier import ClassifierUnavailablefrom ticket_api.main import appfrom 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 :
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 :
class BrokenClassifier: version = "broken"
def predict(self, text: str): raise ClassifierUnavailable("the classifier is not available")
@pytest.fixturedef 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.textraise_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 :
> assert response.status_code == 503E assert 500 == 503FAILED 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 :
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 :
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 :
tests/test_classify.py .......... [ 83%]tests/test_contract.py .. [100%]
============================== 12 passed in 0.12s ==============================Exercice guidé : tester tous les chemins
Section intitulée « Exercice guidé : tester tous les chemins »- Créez
tests/test_classify.pyavec les cas normaux et invalides. Exécutezpython -m pytest. Tous les tests réussissent. - Ajoutez
ClassifierUnavailableàclassifier.py, ainsi que le test du classificateur défaillant. Exécutez les tests. Le nouveau test échoue avecassert 500 == 503. - 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 encore503.) - Documentez
503sur la route. Le test du contrat échoue. Lisez la différence qu’il signale. - Exécutez
python -m ticket_api.export_contract, puis exécutez les tests de nouveau. Les 12 tests réussissent.
À vous de jouer : deux autres cas
Section intitulée « À vous de jouer : deux autres cas »Ajoutez un test pour chaque cas et prédisez le résultat avant de l’exécuter :
GET /v1/categoriesrenvoie exactement les cinq catégories, dans l’ordre.- 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"] == 3Le 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 :
- Modifiez l’état partagé seulement dans une fixture, puis annulez le changement après
yield, comme le faitbroken_classifier. Le code aprèsyields’exécute même si le test échoue. - Ne définissez jamais un remplacement dans le corps d’une fonction de test.
- 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.
Votre réponse contient-elle les points principaux de la nôtre ?
Connectez-vous et inscrivez-vous au cours pour enregistrer vos réponses. Chaque vérification compte pour le certificat.
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é.
parametrizetransforme les lignes du contrat en cas de test. app.dependency_overridesremplace une dépendance dans un test. Installez le remplacement dans une fixture et effacez-le aprèsyield.- Une dépendance indisponible produit
503avec son propre code, et non500. 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