Aller au contenu
English

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

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 JupyterColab · Kaggle · GitHub · en anglais

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 mainEnviron 50 minutes

Ce que vous devez savoir

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.txt et requirements-lock.txt, vus dans Isoler les dépendances : noter les versions. Cette leçon y ajoute pytest.
  • data/tickets.csv et data/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 commande curl. Les sommes de contrôle se trouvent dans la fiche du jeu de données.
  • L’environnement .venv du 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.

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 par test_.
  • Le nom des fonctions de test commence par test_.
  • Un test réussit s’il se termine sans exception. Un assert qui é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.

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.

  1. Ajoutez une ligne à requirements.txt :

    requirements.txt
    httpx==0.28.1
    pytest==9.1.1
  2. Installez-le et mettez à jour le fichier de verrouillage :

    Fenêtre de terminal
    python -m pip install -r requirements.txt
    python -m pip freeze > requirements-lock.txt

    Le fichier de verrouillage contient maintenant aussi pytest et les paquets nécessaires à pytest, comme pluggy et iniconfig.

  3. 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 :

tests/test_files.py
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 :

Fenêtre de terminal
python -m pytest
Sortie
============================= test session starts ==============================
platform darwin -- Python 3.14.6, pytest-9.1.1, pluggy-1.6.0
rootdir: /Users/amira/projects/ticket-cleaner
plugins: anyio-4.15.1
collected 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 :

Output (the important part)
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'}]
E
E 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).

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.

tests/test_parsing.py
import pytest
from ticket_cleaner.parsing import find_problems, split_records, to_ticket
from ticket_cleaner.records import Ticket
@pytest.fixture
def 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.fixture crée votre propre fixture. valid_raw donne un nouvel enregistrement valide à chaque test qui le nomme. Un test peut modifier sa copie, comme le fait test_text_is_cleaned, sans affecter les autres tests.
  • @pytest.mark.parametrize exé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.

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.

tests/test_remote.py
import httpx
import 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.

  1. Créez tests/test_files.py et exécutez python -m pytest. Deux tests réussissent.

  2. Changez l’encodage dans read_rows pour utf-8 et 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.

  3. Créez tests/test_parsing.py et tests/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.py affiche 11 points : 6 fonctions de test, dont la fonction paramétrée compte 5 fois.

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

  5. Exécutez les tests avec -v pour voir le nom de chaque test : python -m pytest -v.

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

  1. average_priority renvoie None s’il n’y a aucun billet de soutien.
  2. average_priority arrondit à une décimale : les priorités 1, 1 et 2 donnent 1.3.
  3. count_by_category renvoie les catégories en ordre alphabétique.
  4. build_summary pour un statut qu’aucun billet n’a : selected vaut 0, by_category vaut {} et average_priority vaut None.

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
tests/test_report.py
import pytest
from ticket_cleaner.records import Ticket
from ticket_cleaner.report import average_priority, build_summary, count_by_category
@pytest.fixture
def 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 None

list(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 problems

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

  1. Comparez avec la valeur exacte attendue : assert problems == ["priority must be 1, 2 or 3, not 'high'"].
  2. 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.
  3. 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.

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.

Récapitulatif

  • Un test est une fonction nommée test_... dans un fichier tests/test_....py. Exécutez-les tous avec python -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 avec tmp_path.
  • @pytest.mark.parametrize exécute un test avec plusieurs valeurs. pytest.raises vé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