Tester avec pytest
De `assert` à pytest : lire un rapport d'échec, `pytest.raises`, `parametrize`, `tmp_path` et les fixtures — et quelle forme de code est facile à tester.
Comment voulez-vous apprendre ?
Le problème que nous résolvons
Jusqu'ici, nous avons « testé » le code d'une seule façon : le lancer, lire le résultat et décider s'il avait l'air juste.
Ça marche une fois. Le problème commence la deuxième fois.
Le programme du chapitre 25 avait TAX_RATE à 0.15. Supposons qu'il passe à 0.18. Le programme tourne, tous les nombres s'affichent, rien ne lève d'erreur — et vous n'avez aucun moyen de savoir si quelque chose a changé, à moins de vous souvenir de l'ancien résultat.
Et au bout d'un moment, ce qu'il faudrait retenir n'est plus retenu.
def line_total(price, quantity):
return round(price * quantity * 1.15, 2)
assert line_total(15.0, 1) == 17.25
print("ok")okassert est une phrase simple : « ceci est censé être vrai ». Quand c'est vrai, rien ne se passe ; quand ça ne l'est pas, le programme s'arrête avec une AssertionError.
def line_total(price, quantity):
return round(price * quantity * 1.15, 2)
assert line_total(15.0, 3) == 51.0
print("ok")AssertionErrorC'est la base. Ce chapitre s'appuie dessus avec un outil — pytest — qui lance des centaines de ces affirmations et, quand l'une échoue, dit pourquoi.
À la fin de ce chapitre vous pourrez
- Écrire un fichier de test et lancer
pytest - Lire un rapport d'échec et trouver le problème à partir de lui
- Tester les erreurs avec
pytest.raises - Lancer un même test sur plusieurs cas avec
@pytest.mark.parametrize - Tester du code qui manipule des fichiers avec
tmp_path - Dire quel code est facile à tester, et pourquoi
Ce qu'il vous faut d'abord : Dataclasses et annotations de type.
Un premier test
Deux règles, et c'est tout : le nom du fichier commence par test_, et le nom de la fonction de test aussi. (pytest cherche aussi les fichiers qui se terminent par _test.py, mais dans ce cours nous commençons les noms par test_.)
pricing.py :
TAX_RATE = 0.15
def line_total(price: float, quantity: int) -> float:
if quantity < 1:
raise ValueError(f"quantity must be at least 1: {quantity}")
return round(price * quantity * (1 + TAX_RATE), 2)test_pricing.py :
from pricing import line_total
def test_one_item():
assert line_total(15.0, 1) == 17.25
def test_three_items():
assert line_total(15.0, 3) == 51.75Puis pytest -q :
.. [100%]
2 passed in 0.00sDeux points, deux tests réussis. pytest trouve les fichiers, trouve les fonctions et les lance — rien n'a besoin d'être enregistré quelque part. Le temps à la fin (0.00s) peut être un peu différent sur votre machine.
Le rapport d'échec, c'est l'essentiel
Mettez un nombre volontairement faux dans le deuxième test de test_pricing.py — 51.0 au lieu de 51.75 — et relancez pytest -q :
.F [100%]
=================================== FAILURES ===================================
_______________________________ test_three_items _______________________________
def test_three_items():
> assert line_total(15.0, 3) == 51.0
E assert 51.75 == 51.0
E + where 51.75 = line_total(15.0, 3)
test_pricing.py:9: AssertionError
=========================== short test summary info ============================
FAILED test_pricing.py::test_three_items - assert 51.75 == 51.0
1 failed, 1 passed in 0.01sLe .F sur la première ligne veut dire que le premier test a réussi et que le second a échoué. Un assert simple nous donnait seulement AssertionError — un mot, sans aucune information. Ici on obtient :
- Quel test (
test_three_items) et quelle ligne (test_pricing.py:9) - L'
assertexact qui a cassé, marqué par> assert 51.75 == 51.0— ce qui a été réellement produit, face à ce qui était attenduwhere 51.75 = line_total(15.0, 3)— d'où vient ce nombre- Le décompte sur la dernière ligne :
1 failed, 1 passed
On appelle cela l'introspection des assertions, et c'est la principale raison d'utiliser pytest. Beaucoup d'outils de test demandent une méthode à part, comme assertEqual(a, b), pour obtenir ce résultat ; ici un simple == suffit.
Les erreurs font aussi partie du comportement
Le chapitre 24 disait qu'une fonction doit raise quand l'entrée est mauvaise. Ce comportement est aussi une promesse, et il peut donc être testé :
import pytest
from pricing import line_total
def test_zero_is_rejected():
with pytest.raises(ValueError):
line_total(15.0, 0)
def test_message_names_the_value():
with pytest.raises(ValueError, match="at least 1: -3"):
line_total(15.0, -3).. [100%]
2 passed in 0.01sLe bloc pytest.raises dit « cette erreur est censée se produire ici ». Si elle ne se produit pas, le test échoue. Par exemple, si un test nommé test_one_is_rejected attend une ValueError de line_total(15.0, 1) — alors qu'une quantité de 1 est valide :
F [100%]
=================================== FAILURES ===================================
_____________________________ test_one_is_rejected _____________________________
def test_one_is_rejected():
> with pytest.raises(ValueError):
E Failed: DID NOT RAISE ValueError
test_pricing.py:7: Failed
=========================== short test summary info ============================
FAILED test_pricing.py::test_one_is_rejected - Failed: DID NOT RAISE ValueError
1 failed in 0.01sLe match= vérifie que le message contient ce texte. Ça vaut la peine de l'écrire, car la règle du chapitre 24 — mettre la valeur fautive dans le message — devient alors une promesse testée à part entière.
Un test, plusieurs cas
Écrire quatre fonctions pour quatre cas est ennuyeux, et favorise les erreurs de copier-coller.
import pytest
from pricing import line_total
@pytest.mark.parametrize(
"price, quantity, expected",
[
(15.0, 1, 17.25),
(15.0, 3, 51.75),
(0.0, 5, 0.0),
(100.0, 2, 230.0),
],
)
def test_line_total(price, quantity, expected):
assert line_total(price, quantity) == expectedAvec pytest -v :
============================= test session starts ==============================
collecting ... collected 4 items
test_pricing.py::test_line_total[15.0-1-17.25] PASSED [ 25%]
test_pricing.py::test_line_total[15.0-3-51.75] PASSED [ 50%]
test_pricing.py::test_line_total[0.0-5-0.0] PASSED [ 75%]
test_pricing.py::test_line_total[100.0-2-230.0] PASSED [100%]
============================== 4 passed in 0.01s ===============================Quatre tests distincts à partir d'une seule fonction. Chaque nom porte ses valeurs, donc quand l'un échoue, on voit tout de suite lequel. Écrit sous forme de boucle, le premier échec arrêterait les autres, et on ne saurait pas quelles valeurs étaient en cause.
Mettez 51.0 à la place de 51.75 dans le deuxième cas et lancez pytest -q ; le début du rapport montre aussi les valeurs :
.F.. [100%]
=================================== FAILURES ===================================
_________________________ test_line_total[15.0-3-51.0] _________________________
price = 15.0, quantity = 3, expected = 51.0Quand il faut un fichier — tmp_path
Tester du code qui lit un fichier, comme celui du chapitre 23, demande un fichier. En garder un dans le dépôt est une mauvaise habitude : les tests deviennent liés entre eux, et un test qui modifie le fichier fait échouer un autre.
Supposons que reader.py contienne cette fonction — elle renvoie les lignes non vides d'un fichier texte :
from pathlib import Path
def read_names(path: Path) -> list[str]:
"""Return the non-blank lines of a text file, without spaces at the ends."""
text = path.read_text(encoding="utf-8")
return [line.strip() for line in text.splitlines() if line.strip()]pytest peut donner à chaque test un dossier vide à lui. test_reader.py :
from reader import read_names
def test_blank_lines_are_skipped(tmp_path):
path = tmp_path / "names.txt"
path.write_text("one\n\ntwo\n", encoding="utf-8")
assert read_names(path) == ["one", "two"]
def test_empty_file_gives_empty_list(tmp_path):
path = tmp_path / "names.txt"
path.write_text("", encoding="utf-8")
assert read_names(path) == [].. [100%]
2 passed in 0.01stmp_path est un pathlib.Path — le Path du chapitre 23, qu'on assemble avec / exactement comme là-bas. Le nom du paramètre indique à pytest ce qu'il doit fournir. Chaque test reçoit un nouveau dossier, donc deux tests peuvent utiliser le même nom de fichier sans se gêner.
Mise en place répétée — fixture
Quand plusieurs tests ont besoin de la même donnée, on peut l'écrire une seule fois et lui donner un nom. Ici order_total est une autre fonction de pricing.py qui additionne toute une commande — le fichier complet est dans l'exemple final ci-dessous :
import pytest
from pricing import order_total
@pytest.fixture
def order():
return [("pen", 15.0, 3), ("bag", 850.0, 1)]
def test_total(order):
assert order_total(order) == 1029.25
def test_one_line_removed(order):
assert order_total(order[:1]) == 51.75.. [100%]
2 passed in 0.01sComme avec tmp_path, pytest fait la correspondance par le nom du paramètre. Et la fonction s'exécute de nouveau pour chaque test, donc même si un test modifie la liste, le suivant en reçoit une nouvelle — le problème des données partagées du chapitre 21, supprimé par conception.
Le piège des nombres à virgule flottante
def test_addition():
assert 0.1 + 0.2 == 0.3F [100%]
=================================== FAILURES ===================================
________________________________ test_addition _________________________________
def test_addition():
> assert 0.1 + 0.2 == 0.3
E assert (0.1 + 0.2) == 0.3
test_money.py:2: AssertionError
=========================== short test summary info ============================
FAILED test_money.py::test_addition - assert (0.1 + 0.2) == 0.3
1 failed in 0.01sLe vieux problème du chapitre 5, et les tests sont souvent l'endroit où il pose problème pour la première fois.
import pytest
def test_addition():
assert 0.1 + 0.2 == pytest.approx(0.3). [100%]
1 passed in 0.00spytest.approx dit « assez proche fera l'affaire ». Utilisez-le dans tout test qui implique des flottants — sauf si le nombre a déjà été arrondi avec round(), comme le fait line_total.
Que tester
Une règle vaut plus que toutes les autres : une fonction qui prend des valeurs et renvoie une valeur est facile à tester.
Vous souvenez-vous des fonctions pures du chapitre 21 ? Ce chapitre montre pourquoi elles valent le coup. Tester line_total n'a demandé ni fichier, ni entrée, ni mise en place — seulement l'appeler et regarder le résultat.
En revanche, une fonction qui affiche, demande une saisie ou écrit un fichier a besoin d'une préparation supplémentaire avant de pouvoir être testée. C'est pourquoi report, au chapitre 23, ne prenait pas de fichier : elle prenait une liste et renvoyait une liste.
Quand les tests sont difficiles à écrire, la faute vient en général de la forme du code, pas des tests.
Quoi tester, en trois catégories : ce qui se passe normalement, les cas limites (zéro, vide, un), et ce qui est censé échouer.
Un exemple complet
pricing.py :
"""Prices and tax. Pure functions, which is what makes them testable."""
TAX_RATE = 0.15
def line_total(price: float, quantity: int) -> float:
if quantity < 1:
raise ValueError(f"quantity must be at least 1: {quantity}")
return round(price * quantity * (1 + TAX_RATE), 2)
def order_total(lines: list[tuple[str, float, int]]) -> float:
return round(sum(line_total(price, qty) for _, price, qty in lines), 2)test_pricing.py :
"""Tests for pricing. Each one names the behaviour it protects."""
import pytest
from pricing import line_total, order_total
@pytest.mark.parametrize(
"price, quantity, expected",
[
(15.0, 1, 17.25),
(15.0, 3, 51.75),
(0.0, 5, 0.0),
(0.01, 1, 0.01),
],
)
def test_line_total_applies_tax(price, quantity, expected):
assert line_total(price, quantity) == expected
@pytest.mark.parametrize("quantity", [0, -1, -100])
def test_quantity_below_one_is_rejected(quantity):
with pytest.raises(ValueError, match="at least 1"):
line_total(15.0, quantity)
def test_error_message_names_the_value():
with pytest.raises(ValueError, match="at least 1: -3"):
line_total(15.0, -3)
@pytest.fixture
def order():
return [("pen", 15.0, 3), ("bag", 850.0, 1), ("ink", 120.0, 2)]
def test_order_total_sums_the_lines(order):
assert order_total(order) == 1305.25
def test_empty_order_totals_zero():
assert order_total([]) == 0
def test_one_bad_line_stops_the_order(order):
with pytest.raises(ValueError):
order_total(order + [("clip", 5.0, 0)])........... [100%]
11 passed in 0.01sQuatre choses à remarquer.
Onze tests pour six fonctions de test. parametrize fait la différence, et chaque cas est compté séparément.
Les noms décrivent le comportement, pas le code. test_quantity_below_one_is_rejected vous dit ce que le programme promet ; test_line_total_2 ne vous dit rien. Ce nom est la première chose qu'affiche un rapport d'échec, donc ça vaut la peine d'en faire une phrase.
Le cas 0.01 n'est pas choisi au hasard. C'est le prix le plus petit possible — un cas limite, et précisément l'endroit où le comportement de round() est le plus incertain. Les bugs se cachent aux limites, pas au milieu.
Le dernier test contient une promesse indirecte. order_total ne fait aucune validation elle-même — elle s'appuie sur line_total. Si quelqu'un met un jour un try/except dans order_total pour ignorer discrètement les mauvaises lignes, ce test échouera et demandera : « vouliez-vous vraiment faire ça ? »
Quand ça casse
no tests ran Le nom du fichier ne commence pas par test_ (ou ne se termine pas par _test.py), ou le nom de la fonction ne commence pas par test_. Les deux sont nécessaires.
ModuleNotFoundError: No module named 'pricing' Lancez pytest depuis le dossier où se trouvent les fichiers.
Un test réussit et ne vérifie rien L'assert manque. Le simple fait d'appeler une fonction réussit tant qu'elle ne lève pas d'erreur.
fixture 'order' not found Le @pytest.fixture manque, ou le nom ne correspond pas à celui du paramètre.
Une comparaison de flottants échoue alors que les nombres semblent justes Utilisez pytest.approx, ou round() à l'intérieur de la fonction elle-même.
DID NOT RAISE Le code à l'intérieur du bloc pytest.raises n'a pas levé d'erreur. Soit le code est faux, soit l'attente du test l'est.
Un test réussit seul et échoue avec les autres Les tests partagent quelque chose — souvent une liste ou un dictionnaire au niveau du module. Ce sont les données mutables partagées des chapitres 21 et 26. Utilisez une fixture.
Étape 4 sur 6 — Prédire
Vérifiez votre compréhension
En lançant pytest -q, quelle est la dernière ligne du rapport ?
# checks.py
def is_even(n):
return n % 2 == 0
# test_checks.py
from checks import is_even
def test_even():
assert is_even(4)
def test_odd():
assert is_even(7)- A2 failed in 0.01s
- B1 failed, 1 passed in 0.01s
- C1 failed in 0.01s
- D2 passed in 0.01s
add(2, 2) n'est pas 5. Que dit pytest -q ?
# test_a.py
def add(a, b):
return a + b
def test_add():
add(2, 2) == 5- AUne `AssertionError`
- B1 failed — comme il se doit
- Cno tests ran
- D1 passed — le test réussit
Le premier test a mis un article dans le panier. Que voit le second ?
# helpers.py
def build():
return []
# test_helpers.py
import pytest
from helpers import build
@pytest.fixture
def basket():
return build()
def test_first(basket):
basket.append("one")
assert len(basket) == 1
def test_second(basket):
assert len(basket) == 0- Afixture 'basket' not found
- B2 failed
- C2 passed — le second test reçoit une liste vide neuve
- D1 failed, 1 passed — le second voit un article
Répondre nécessite un compte
Connectez-vous pour vérifier vos réponses
Les questions sont ci-dessus, et les résoudre de tête est ce qui compte vraiment. Connectez-vous pour voir les réponses, les explications et les indices à trois niveaux.
À vous de jouer
Prenez library.py du chapitre 27 — Book et Shelf — et écrivez test_library.py à côté.
Au minimum :
- Plusieurs cas de
total_pages()avecparametrize, dont une étagère vide pytest.raisesavecmatch=pour le rejet depages=0longest()renvoyantNonesur une étagère videby_authorrenvoyant exactement les livres de cet auteur, et une liste vide pour un auteur inconnu- Un test utilisant
tmp_path, qui écrit l'étagère en JSON puis la relit
Puis cinq expériences :
- Retirez l'
assertd'un test et appelez seulement la fonction. Le test réussit-il ? - Renommez le fichier de test en
check_library.pyet lancezpytest. Que se passe-t-il ? (Nommélibrary_test.py, il est trouvé — pourquoi ?) - Retirez la vérification
pages < 1deBook. Quels tests échouent, et le rapport dit-il pourquoi ? - Introduisez une erreur volontaire dans
total_pages()—sum(...) + 1, par exemple. Le rapport d'échec montre-t-il à la fois le nombre obtenu et le nombre attendu ? - Écrivez un test qui affirme
0.1 + 0.2 == 0.3. Corrigez-le ensuite avecpytest.approx.
La troisième expérience est la plus instructive. Une bonne suite de tests, quand elle casse, vous dit quelle promesse a été brisée — et c'est ce qui permet de modifier un programme sereinement six mois plus tard.
Étape 6 sur 6
Défi — le quiz du chapitre
Dix questions, de la plus facile à la plus difficile. Les dernières sont difficiles exprès.
Connectez-vous pour faire le quiz