Des tests ciblés
- Leçon 15
- 50 min
- Module 5 : Débogage et fiabilité
Leçon 15 sur 18 · Python pour l'ingénierie de l'IA en pratiqueDes tests ciblés
1. Bases de l'ordinateur et de l'environnement
2. Valeurs et flux de contrôle
3. Fonctions et structure du programme
4. Fichiers, données et services externes
5. Débogage et fiabilité
6. Du carnet Jupyter au projet facile à maintenir
Ce que vous saurez faire
Transformez vos vérifications en tests qui s’exécutent en une seconde, avec une seule commande, chaque fois que vous modifiez le code. Votre premier test prouve que le bogue d’encodage de Lire les erreurs ne peut pas revenir sans vous avertir.
Carnet JupyterFaites cette leçon dans un carnet Jupyter
Le carnet (notebook) Jupyter contient tous les tests, tâches et vérifications de cette leçon, avec les consignes. Ses premières cellules créent le dossier ticket-cleaner dans son état au début de cette leçon. Elles installent aussi httpx et pytest et téléchargent les fichiers de données. Le carnet fonctionne donc sans votre projet local. Lisez les explications ici et exécutez le code là-bas.
Le carnet est en anglais. Colab et Kaggle ouvrent votre propre copie. Il faut un compte Google ou Kaggle gratuit. Vous êtes bloqué ? Consultez le carnet de solution.
Avant de commencerCe qu’il faut savoir et avoir sous la main
Ce que vous devez savoir
assert, vu dans Le contrat d’une fonction : vérifier chaque fonction séparément.- L’exemple minimal qui reproduit le bogue d’encodage, vu dans Lire les erreurs : erreur 2.
- Les réponses simulées, vues dans Utiliser un client HTTP simple, et
tryetexcept, vus dans Exceptions et journalisation.
Ce qu’il vous faut sur votre ordinateur (seulement pour le parcours local; le carnet n’en a pas besoin)
- ticket-cleaner dans son état à la fin de Exceptions et journalisation. Il lit les fichiers CSV avec
utf-8-sig, rejette les valeurs qui ne sont pas des dictionnaires et journalise ses erreurs. Si votre version diffère, refaites cette section. Vous pouvez aussi copier les fichiers de la cellule de préparation du carnet de cette leçon. Elle contient tous les fichiers dans leur état au début de cette leçon. requirements.txtetrequirements-lock.txt, vus dans Isoler les dépendances : noter les versions. Cette leçon y ajoute pytest.data/tickets.csvetdata/tickets.json. Vous les avez téléchargés dans Lire et écrire des fichiers : obtenir le fichier de données. Si un fichier manque ou a changé, téléchargez-le à nouveau avec la même commandecurl. Les sommes de contrôle se trouvent dans la fiche du jeu de données.- L’environnement
.venvdu projet, actif dans votre terminal (l’invite commence par(.venv)). Vous l’avez créé dans Isoler les dépendances : exercice guidé. S’il manque ou ne fonctionne plus, supprimez-le et recréez-le à partir du fichier de verrouillage. Suivez Recréer l’environnement à partir de zéro. - Une connexion Internet pour installer pytest (environ 2 Mo).
Testé avec Python 3.14.6, pytest 9.1.1 et httpx 0.28.1 sur macOS. Le carnet a été testé dans Jupyter.
De assert à un test
Section intitulée « De assert à un test »Dans Le contrat d’une fonction, vous avez vérifié des fonctions avec des lignes assert dans un script. Cela fonctionne, mais le premier assert qui échoue arrête le script. Vous ne voyez donc qu’un problème à la fois. Et personne n’exécute un script dont il a oublié l’existence.
Un test est une fonction qui vérifie un fait sur votre code avec assert. Un exécuteur de tests trouve tous les tests, exécute chacun séparément et signale tous les échecs. L’exécuteur de tests le plus courant pour Python est pytest. Ses règles sont simples :
- Les fichiers de test sont dans un dossier nommé
tests, et leur nom commence partest_. - Le nom des fonctions de test commence par
test_. - Un test réussit s’il se termine sans exception. Un
assertqui échoue fait échouer le test, et pytest montre les valeurs qui diffèrent.
Un bon nom de test décrit le fait vérifié, comme une phrase : test_duplicate_id_is_rejected. En cas d’échec, le nom vous indique ce qui ne fonctionne plus avant même de lire le code.
Installer pytest
Section intitulée « Installer pytest »pytest est une dépendance de développement : le programme n’en a pas besoin pour s’exécuter, mais vous en avez besoin pour travailler dessus. Ajoutez-le aux dépendances du projet comme dans Isoler les dépendances.
-
Ajoutez une ligne à
requirements.txt:requirements.txt httpx==0.28.1pytest==9.1.1 -
Installez-le et mettez à jour le fichier de verrouillage :
Fenêtre de terminal python -m pip install -r requirements.txtpython -m pip freeze > requirements-lock.txtLe fichier de verrouillage contient maintenant aussi
pytestet les paquets nécessaires à pytest, commepluggyetiniconfig. -
Créez le dossier
tests:mkdir tests.
Exemple détaillé : le test de non-régression d’abord
Section intitulée « Exemple détaillé : le test de non-régression d’abord »Une régression est un bogue qui revient après sa correction. Un test de non-régression reproduit le bogue. Il échoue tant que le bogue existe et réussit une fois le bogue corrigé. Écrivez-le à partir de l’exemple minimal qui échoue :
import json
from ticket_cleaner.files import read_rows, write_json
def test_csv_with_byte_order_mark(tmp_path): path = tmp_path / "tickets.csv" path.write_text("id,status\nT-1001,open\n", encoding="utf-8-sig") assert read_rows(path) == [{"id": "T-1001", "status": "open"}]
def test_write_json_makes_the_folder(tmp_path): path = tmp_path / "reports" / "summary.json" write_json({"selected": 0}, path) assert json.loads(path.read_text(encoding="utf-8")) == {"selected": 0} assert not (tmp_path / "reports" / "summary.json.tmp").exists()tmp_path est une fixture : une valeur que pytest crée et fournit à un test qui la nomme comme paramètre. tmp_path est un nouveau dossier vide pour chaque test, que pytest supprime ensuite. Les tests qui écrivent des fichiers l’utilisent. Ainsi, ils ne modifient jamais les fichiers de votre projet et ne dépendent jamais les uns des autres. path.write_text(...) écrit un fichier texte complet en un seul appel.
Le deuxième test vérifie les engagements de write_json : créer un dossier manquant, écrire les données et ne laisser aucun fichier temporaire.
Exécutez les tests. python -m pytest exécute pytest avec l’interpréteur de l’environnement actif, depuis le dossier du projet. Les tests peuvent ainsi importer ticket_cleaner :
python -m pytest============================= test session starts ==============================platform darwin -- Python 3.14.6, pytest-9.1.1, pluggy-1.6.0rootdir: /Users/amira/projects/ticket-cleanerplugins: anyio-4.15.1collected 2 items
tests/test_files.py .. [100%]
============================== 2 passed in 0.02s ===============================Chaque . représente un test réussi. (Le module d’extension anyio est fourni avec httpx. Vous pouvez l’ignorer.)
Prouvez que le test peut échouer. Un test qui réussit avec ou sans le bogue ne prouve rien. Dans files.py, remplacez utf-8-sig par utf-8 et relancez les tests :
tests/test_files.py F. [100%]
=================================== FAILURES ===================================________________________ test_csv_with_byte_order_mark _________________________
def test_csv_with_byte_order_mark(tmp_path): path = tmp_path / "tickets.csv" path.write_text("id,status\nT-1001,open\n", encoding="utf-8-sig")> assert read_rows(path) == [{"id": "T-1001", "status": "open"}]E AssertionError: assert [{'\ufeffid':...tus': 'open'}] == [{'id': 'T-10...tus': 'open'}]EE At index 0 diff: {'\ufeffid': 'T-1001', 'status': 'open'} != {'id': 'T-1001', 'status': 'open'}
tests/test_files.py:9: AssertionError=========================== short test summary info ============================FAILED tests/test_files.py::test_csv_with_byte_order_mark - AssertionError: a...========================= 1 failed, 1 passed in 0.03s ==========================F indique un test échoué. Le > marque la ligne qui a échoué. Les lignes avec E montrent les deux valeurs : le résultat de la fonction, avec '\ufeffid', et la valeur attendue. Voilà le bogue, trouvé par un test en une fraction de seconde. Rétablissez l’encodage utf-8-sig et relancez les tests : 2 passed (2 tests réussis).
Tester le contrat : cas normaux, limites et erreurs
Section intitulée « Tester le contrat : cas normaux, limites et erreurs »Pour chaque fonction, testez trois types d’entrée :
| Type | Question | Exemples pour find_problems |
|---|---|---|
| Normal | La fonction traite-t-elle correctement une entrée habituelle? | Un enregistrement valide n’a aucun problème. |
| Limite | La fonction traite-t-elle correctement les valeurs aux limites? | Les priorités 1 et 3 sont valides. 0 et 4 ne le sont pas. Une entrée vide. |
| Cas d’erreur | La fonction signale-t-elle correctement une entrée incorrecte? | Un champ manquant, high, None au lieu d’un enregistrement. |
Les bogues s’accumulent aux limites : < au lieu de <=, une liste vide, un premier ou un dernier élément. Un test pour chaque limite les trouve.
import pytest
from ticket_cleaner.parsing import find_problems, split_records, to_ticketfrom ticket_cleaner.records import Ticket
@pytest.fixturedef valid_raw(): return {"id": "T-1001", "status": "open", "category": "billing", "priority": "2"}
def test_valid_record_has_no_problems(valid_raw): assert find_problems(valid_raw) == []
def test_text_is_cleaned(valid_raw): valid_raw["id"] = " t-1001 " valid_raw["status"] = "Open " expected = Ticket(id="T-1001", status="open", category="billing", priority=2) assert to_ticket(valid_raw) == expected
@pytest.mark.parametrize("priority", ["0", "4", "high", "2.5", "-1"])def test_priority_outside_1_to_3_is_a_problem(valid_raw, priority): valid_raw["priority"] = priority assert find_problems(valid_raw) == [f"priority must be 1, 2 or 3, not {priority!r}"]
def test_missing_field_is_a_problem(valid_raw): del valid_raw["category"] assert find_problems(valid_raw) == ["missing category"]
def test_duplicate_id_is_rejected(valid_raw): tickets, rejected = split_records([valid_raw, dict(valid_raw)]) assert len(tickets) == 1 assert rejected[0].row == 2 assert rejected[0].problems == ["duplicate id T-1001"]
def test_a_value_that_is_not_a_dictionary_is_rejected(): tickets, rejected = split_records([None]) assert tickets == [] assert rejected[0].problems == ["not a record"]
def test_empty_input_gives_empty_results(): assert split_records([]) == ([], [])Trois nouveaux outils :
@pytest.fixturecrée votre propre fixture.valid_rawdonne un nouvel enregistrement valide à chaque test qui le nomme. Un test peut modifier sa copie, comme le faittest_text_is_cleaned, sans affecter les autres tests.@pytest.mark.parametrizeexécute un test plusieurs fois avec différentes valeurs. Ici, une fonction de test devient cinq tests, un pour chaque priorité incorrecte. Dans la sortie, chacun compte séparément.del valid_raw["category"]supprime une clé du dictionnaire pour créer un enregistrement avec un champ manquant.
Les deux derniers tests reprennent le cas d’erreur et l’entrée vide des leçons précédentes. Ils sont aussi devenus des tests.
Tester le client web sans le réseau
Section intitulée « Tester le client web sans le réseau »La simulation de Utiliser un client HTTP simple devient deux tests. pytest.raises vérifie le cas d’erreur : le test réussit seulement si le code du bloc with lève ce type d’exception.
import httpximport pytest
from ticket_cleaner.remote import fetch_rows
URL = "https://tickets.example/api/tickets"
def answer_ok(request: httpx.Request) -> httpx.Response: return httpx.Response(200, json=[{"id": "T-2001", "status": "open"}])
def answer_unavailable(request: httpx.Request) -> httpx.Response: return httpx.Response(503, text="Service Unavailable")
def test_fetch_rows_returns_the_records(): client = httpx.Client(transport=httpx.MockTransport(answer_ok)) assert fetch_rows(URL, client=client) == [{"id": "T-2001", "status": "open"}]
def test_fetch_rows_raises_on_server_error(): client = httpx.Client(transport=httpx.MockTransport(answer_unavailable)) with pytest.raises(httpx.HTTPStatusError): fetch_rows(URL, client=client)Ces tests n’ont pas besoin d’Internet et donnent toujours le même résultat. Un test qui dépend d’un vrai serveur échoue si le serveur ou votre connexion a un problème. Cela arrive même si votre code est correct.
Exercice guidé : la suite de tests
Section intitulée « Exercice guidé : la suite de tests »-
Créez
tests/test_files.pyet exécutezpython -m pytest. Deux tests réussissent. -
Changez l’encodage dans
read_rowspourutf-8et exécutez les tests.test_csv_with_byte_order_markéchoue, avec la différence'\ufeffid'. Rétablissez l’encodage et relancez les tests. Les deux réussissent. Vous avez maintenant la preuve que le test échoue avant la correction et réussit après. -
Créez
tests/test_parsing.pyettests/test_remote.py. Exécutez tous les tests :Sortie (dernières lignes) tests/test_files.py .. [ 13%]tests/test_parsing.py ........... [ 86%]tests/test_remote.py .. [100%]============================== 15 passed in 0.06s ==============================test_parsing.pyaffiche 11 points : 6 fonctions de test, dont la fonction paramétrée compte 5 fois. -
Exécutez un groupe de tests par nom avec
-k:python -m pytest -k duplicate. Seul le test des doublons s’exécute. C’est utile quand vous travaillez sur une seule fonction. -
Exécutez les tests avec
-vpour voir le nom de chaque test :python -m pytest -v.
À vous de jouer : les limites du rapport
Section intitulée « À vous de jouer : les limites du rapport »Créez tests/test_report.py avec des tests pour report.py. Écrivez au moins les tests suivants et donnez à chacun un nom qui décrit le fait vérifié :
average_priorityrenvoieNones’il n’y a aucun billet de soutien.average_priorityarrondit à une décimale : les priorités1,1et2donnent1.3.count_by_categoryrenvoie les catégories en ordre alphabétique.build_summarypour un statut qu’aucun billet n’a :selectedvaut0,by_categoryvaut{}etaverage_priorityvautNone.
Créez les objets Ticket dans chaque test ou avec une fixture. Puis faites exprès de rendre chaque fonction incorrecte, une à la fois, et vérifiez qu’un test échoue.
Afficher une réponse
import pytest
from ticket_cleaner.records import Ticketfrom ticket_cleaner.report import average_priority, build_summary, count_by_category
@pytest.fixturedef tickets(): return [ Ticket(id="T-1", status="open", category="login", priority=1), Ticket(id="T-2", status="open", category="billing", priority=1), Ticket(id="T-3", status="closed", category="billing", priority=2), ]
def test_average_priority_of_no_tickets_is_none(): assert average_priority([]) is None
def test_average_priority_is_rounded_to_one_decimal(tickets): assert average_priority(tickets) == 1.3
def test_categories_are_in_alphabetical_order(tickets): assert list(count_by_category(tickets)) == ["billing", "login"]
def test_summary_for_a_status_with_no_tickets(tickets): summary = build_summary(tickets, [], status="pending") assert summary["selected"] == 0 assert summary["by_category"] == {} assert summary["average_priority"] is Nonelist(a_dictionary) donne ses clés dans l’ordre. Le troisième test vérifie donc l’ordre, pas seulement les nombres.
Erreur courante
Un test qui réussit pour la mauvaise raison
Symptôme : un test réussit, mais la fonction est incorrecte. Ou un test réussit avant et après la correction.
def test_find_problems_for_bad_priority(valid_raw): valid_raw["priority"] = "high" problems = find_problems(valid_raw) assert problemsCause : assert problems vérifie seulement que la liste n’est pas vide. Si find_problems signale le mauvais problème, par exemple missing priority (priorité manquante), le test réussit quand même. Autres causes : une fonction de test dont le nom ne commence pas par test_ (pytest ne l’exécute jamais). Ou un test sans aucun assert.
Correction :
- Comparez avec la valeur exacte attendue :
assert problems == ["priority must be 1, 2 or 3, not 'high'"]. - Rendez le code incorrect volontairement et vérifiez que le test échoue. Faites-le pour chaque nouveau test, comme vous l’avez fait pour l’encodage.
- Vérifiez le nombre de tests dans la sortie. S’il n’a pas augmenté après l’ajout d’un test, pytest ne l’a pas trouvé.
Vérifiez votre compréhension
Pourquoi le test d'encodage écrit-il son fichier dans tmp_path plutôt que dans data/?
Afficher la réponse
tmp_path est un nouveau dossier vide pour chaque test. Le test ne peut pas modifier vos vraies données. Il ne dépend pas non plus d’un fichier que quelqu’un pourrait modifier ou supprimer. Chaque test crée exactement l’entrée nécessaire. Il donne donc le même résultat sur tous les ordinateurs.
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.
Vérifiez votre compréhension
Vérification du module : montrez que le test de non-régression échoue avant la correction et réussit après. Quelles deux commandes exécutez-vous, et que changez-vous entre les deux?
Afficher la réponse
Changez l’encodage dans read_rows pour utf-8 (le bogue) et exécutez python -m pytest. test_csv_with_byte_order_mark échoue, et les lignes E montrent '\ufeffid' au lieu de 'id'. Rétablissez utf-8-sig (la correction) et exécutez encore python -m pytest : tous les tests réussissent. Conservez les deux sorties comme preuves.
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
- Un test est une fonction nommée
test_...dans un fichiertests/test_....py. Exécutez-les tous avecpython -m pytest. - Testez le cas normal, les limites et les cas d’erreur. Comparez avec des valeurs exactes.
- Les fixtures donnent des données neuves à chaque test : les vôtres avec
@pytest.fixture, et un dossier temporaire avectmp_path. @pytest.mark.parametrizeexécute un test avec plusieurs valeurs.pytest.raisesvérifie qu’une erreur est levée.- Simulez le réseau dans les tests. Écrivez un test de non-régression pour chaque bogue corrigé et vérifiez qu’il échoue sans la correction.
Vous pouvez maintenant trouver les défaillances, gérer les erreurs et prouver avec des tests que le code fonctionne. Dans le dernier module, vous transformez ticket-cleaner en un projet qu’une autre personne peut installer et exécuter.
Leçon suivante : De la rigueur dans le carnet