assert et lecture des rapports d'échec
Comment pytest voit à l'intérieur d'un simple assert, comment lire un rapport d'échec ligne par ligne, et quelle comparaison écrire pour que le rapport soit le plus instructif possible.
Comment voulez-vous apprendre ?
Le problème que nous résolvons
Voici une vérification écrite en Python pur, dans un fichier nommé check.py :
def full_name(first, last):
return f"{first} {last}".title()
assert full_name("ada", "lovelace") == "Ada Lovelace "Traceback (most recent call last):
File "/home/you/shop/check.py", line 5, in <module>
assert full_name("ada", "lovelace") == "Ada Lovelace "
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
AssertionErrorPython vous indique que l'affirmation était fausse, et rien d'autre. Qu'a réellement renvoyé full_name ? Vous devriez ajouter un print, réexécuter le script et regarder.
Placez la même ligne dans un test, test_names.py, et exécutez pytest -q :
from names import full_name
def test_full_name():
assert full_name("ada", "lovelace") == "Ada Lovelace "F [100%]
=================================== FAILURES ===================================
________________________________ test_full_name ________________________________
def test_full_name():
> assert full_name("ada", "lovelace") == "Ada Lovelace "
E AssertionError: assert 'Ada Lovelace' == 'Ada Lovelace '
E
E - Ada Lovelace
E ? -
E + Ada Lovelace
test_names.py:5: AssertionError
=========================== short test summary info ============================
FAILED test_names.py::test_full_name - AssertionError: assert 'Ada Lovelace' ...
1 failed in 0.01sÀ présent, vous pouvez voir les deux valeurs, ainsi qu'un marqueur sous l'unique caractère qui diffère : une espace en fin de chaîne dans la valeur attendue. La fonction avait raison ; le test avait tort.
Un test qui échoue n'est utile qu'à la mesure de ce qu'il vous apprend. Ce chapitre vous apprend à lire tout ce que pytest vous indique, et à écrire des asserts qui lui donnent quelque chose d'utile à dire.
À la fin de ce chapitre, vous saurez
- Expliquer ce qu'est la réécriture d'assertions et où elle s'applique ou non
- Lire un rapport d'échec ligne par ligne :
>,E,where,file:lineet le résumé court - Choisir entre
==,in,is None,is Trueet un simpleassert x - Lire les diffs de listes, de dictionnaires et de chaînes, et demander le diff complet avec
-vv - Ajouter un message à un assert, et éviter le tuple qui est toujours vrai
- Adapter le format d'un rapport avec
--tb=short,--tb=line,--tb=noet-l
Prérequis : installer pytest et votre premier test.
Avant d'écrire le test
Un assert est une promesse formulée par écrit. Avant d'en taper un, trois questions doivent trouver réponse, et aucune n'implique pytest.
Qu'est-ce qui est promis exactement ? Prenez la fonction sur laquelle se termine ce chapitre, summarise(lines), qui lit des lignes de facture comme "pen, 12, 15.0". Son contrat en une phrase : elle ignore les lignes vides et renvoie un dictionnaire contenant le nombre d'articles, leurs noms dans l'ordre fourni et le total arrondi à deux décimales. Chaque assert que vous écrirez plus tard constitue une clause de cette phrase. Si vous ne pouvez pas énoncer cette phrase, vous n'êtes pas prêt à écrire le test — vous finiriez par tester ce que le code fait par hasard.
Que faut-il mettre en place ? Très peu de choses pour ce chapitre : l'environnement virtuel du premier chapitre avec pytest installé (python -m pytest --version répond à l'appel), et le module testé importable depuis l'endroit où vous lancez pytest — ici invoice.py se trouve à côté de test_invoice.py et vous exécutez pytest depuis ce dossier. Pas de fichiers, pas de réseau, pas de fixtures.
Quels cas, et qu'attend chacun d'eux ? Calculez les valeurs attendues à la main, avant d'exécuter le code. 12 × 15.0 + 2 × 850.0 donne 1880.0 ; ce nombre provient de l'arithmétique, et non de ce que summarise a affiché. Si vous copiez plutôt la sortie dans le test, le test enregistre le comportement d'aujourd'hui, bugs compris, et défendra ce bug à partir de cet instant.
| Cas | Entrée | Attendu | Assert que vous écrirez | | --- | --- | --- | --- | | Une ligne, des espaces autour du nom | " pen , 12, 15.0" | {"name": "pen", "qty": 12, "price": 15.0} | == sur le dictionnaire entier | | Les lignes vides sont ignorées | ["pen, 12, 15.0", "", "bag, 2, 850.0"] | le compte est 2 | == | | Le résumé complet | les trois mêmes lignes | {"count": 2, "names": ["pen", "bag"], "total": 1880.0} | == sur le dictionnaire entier | | Recherche d'un article qui existe | "pen" | un enregistrement, pas None | is not None | | Recherche d'un article qui n'existe pas | liste vide, "pen" | None | is None | | Le total est cohérent | les trois mêmes lignes | supérieur à 0 | > avec un message |
La dernière colonne est le sujet de ce chapitre : pour chaque promesse, la comparaison qui l'exprime le plus précisément et qui échoue avec le rapport le plus utile.
Ce qu'il ne faut pas tester. Que str.split découpe, que round arrondisse, que int("12") donne 12 — Python teste déjà ces éléments. Ce n'est pas non plus à vous de tester la formulation exacte de la sortie de pytest. Une ligne mal formée telle que "pen, twelve, 15.0" a sa place dans le plan, mais affirmer qu'une erreur est levée nécessite pytest.raises, qui fait l'objet du chapitre quatre ; notez la ligne dès maintenant et écrivez le test à ce moment-là.
Comment pytest voit à l'intérieur d'un assert
Python pur jette les valeurs dès qu'un assert échoue. Pytest les conserve parce qu'il modifie votre fichier de test avant de l'exécuter.
Quand pytest importe un module de test, il repère chaque instruction assert et la réécrit sous forme de code qui stocke chaque valeur intermédiaire — le résultat de full_name(...), la chaîne de caractères à droite — avant de vérifier la condition. Lorsque la vérification échoue, ces valeurs stockées deviennent le rapport. C'est la réécriture d'assertions (assertion rewriting), et c'est pourquoi pytest n'a besoin d'aucun assertEqual ni assertIn : un simple assert avec un simple == suffit.
Désactivez-la pour voir ce qu'elle fait pour vous. pytest -q --assert=plain sur le même test :
def test_full_name():
> assert full_name("ada", "lovelace") == "Ada Lovelace "
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
E AssertionError
test_names.py:5: AssertionError
=========================== short test summary info ============================
FAILED test_names.py::test_full_name - AssertionErrorRetour à un seul mot nu. La réécriture s'applique aux fichiers de test (test_*.py, *_test.py) et à conftest.py. Elle ne s'applique pas à vos autres modules, ce qui a son importance dès que vous placez un assert dans un helper — la section « En cas de problème » montre à quoi cela ressemble.
Lire un rapport d'échec, ligne par ligne
Un petit module, cart.py :
def subtotal(items):
return sum(price * qty for price, qty in items)
def total(items, discount):
return subtotal(items) - discountEt test_cart.py, où le second test attend le mauvais nombre :
from cart import subtotal, total
def test_subtotal():
assert subtotal([(15.0, 2), (60.0, 1)]) == 90.0
def test_total_with_discount():
items = [(15.0, 2), (60.0, 1)]
assert total(items, 10) == 70.0Exécutez simplement pytest :
============================= test session starts ==============================
platform linux -- Python 3.12.3, pytest-9.1.1, pluggy-1.6.0
rootdir: /home/you/shop
collected 2 items
test_cart.py .F [100%]
=================================== FAILURES ===================================
___________________________ test_total_with_discount ___________________________
def test_total_with_discount():
items = [(15.0, 2), (60.0, 1)]
> assert total(items, 10) == 70.0
E assert 80.0 == 70.0
E + where 80.0 = total([(15.0, 2), (60.0, 1)], 10)
test_cart.py:10: AssertionError
=========================== short test summary info ============================
FAILED test_cart.py::test_total_with_discount - assert 80.0 == 70.0
========================= 1 failed, 1 passed in 0.01s ==========================Lisez-le depuis le début :
test_cart.py .F— un caractère par test, dans l'ordre..pour un succès,Fpour un échec.____ test_total_with_discount ____— l'en-tête d'un échec. Chaque test en échec a droit à sa propre section.- Les lignes sources — le corps du test jusqu'à la ligne qui a échoué.
>— la ligne exacte qui a levé l'exception. Tout ce qui précède s'est exécuté sans encombre.E assert 80.0 == 70.0— l'assert à nouveau, chaque expression étant remplacée par sa valeur. Le côté gauche valait80.0, le côté droit70.0.E + where 80.0 = total(...)— d'où provient une valeur. Pour un appel de fonction, pytest affiche l'appel avec ses arguments renseignés. Des appels imbriqués produisent des ligneswhereimbriquées, chacune avec une indentation supplémentaire.test_cart.py:10: AssertionError— fichier et ligne, pour y accéder directement dans votre éditeur, ainsi que le type d'exception.short test summary info— une ligne par échec,FAILED path::test_name - first line of the error. Avec de nombreux échecs, lisez d'abord cette liste.- La dernière ligne — les totaux et le temps écoulé.
Pourquoi lire dans cet ordre ? Parce que chaque ligne affine la recherche. Le résumé vous indique quels tests ; la ligne > quelle affirmation ; la ligne E quel côté est incorrect ; la ligne where quel appel l'a produit. C'est seulement à ce moment-là que vous ouvrez le code — et vous l'ouvrez au niveau de la bonne fonction.
Parfois, la première ligne E affiche AssertionError: assert ... et parfois simplement assert .... Il s'agit du même échec ; le préfixe subsiste lorsque les valeurs contiennent des guillemets.
Les comparaisons que vous écrirez le plus
Toute expression peut suivre assert, et pytest explique les plus courantes. users.py :
def find_user(users, email):
for user in users:
if user["email"] == email:
return user
return None
def active_emails(users):
return [u["email"] for u in users if u["active"]]Quatre tests, quatre sortes de comparaison :
from users import active_emails, find_user
ADA = {"email": "[email protected]", "active": True}
ALAN = {"email": "[email protected]", "active": False}
def test_equal():
assert active_emails([ADA, ALAN]) == ["[email protected]", "[email protected]"]
def test_in():
assert "[email protected]" in active_emails([ADA, ALAN])
def test_is_not_none():
assert find_user([ADA], "[email protected]") is not None
def test_greater():
assert len(active_emails([ADA, ALAN])) > 1Tous les quatre échouent. Les lignes > et E de chacun, extraites d'une exécution de pytest -q :
> assert active_emails([ADA, ALAN]) == ["[email protected]", "[email protected]"]
E AssertionError: assert ['[email protected]'] == ['ada@[email protected]']
E
E Right contains one more item: '[email protected]'
E Use -v to get more diff
> assert "[email protected]" in active_emails([ADA, ALAN])
E AssertionError: assert '[email protected]' in ['[email protected]']
E + where ['[email protected]'] = active_emails([{'email': '[email protected]', 'active': True}, {'email': '[email protected]', 'active': False}])
> assert find_user([ADA], "[email protected]") is not None
E AssertionError: assert None is not None
E + where None = find_user([{'email': '[email protected]', 'active': True}], '[email protected]')
> assert len(active_emails([ADA, ALAN])) > 1
E AssertionError: assert 1 > 1
E + where 1 = len(['[email protected]'])
E + where ['[email protected]'] = active_emails([{'email': '[email protected]', 'active': True}, {'email': '[email protected]', 'active': False}])Chacun dit quelque chose de différent :
==compare des valeurs. Pour les listes, pytest indique également comment elles diffèrent — le côté droit a un élément de plus. (active_emailsa raison d'exclure Alan ; l'attente de ce test était erronée.)invérifie l'appartenance, et montre l'ensemble du conteneur pour que vous puissiez voir ce qui s'y trouvait à la place.is None/is not Nonevérifie l'identité avec l'unique objetNone.find_usera renvoyéNoneparce que la recherche est sensible à la casse.<,>,<=,>=fonctionnent tels qu'ils sont écrits. Deux ligneswhere:1provient delen(...), et cette liste deactive_emails(...).
Pourquoi ce choix a-t-il de l'importance, si chaque forme peut être amenée à échouer ? Parce que le rapport est construit à partir de l'expression que vous avez écrite. La mauvaise façon de demander « Alan est-il dans la liste ? » :
def test_count():
emails = ["[email protected]"]
assert emails.count("[email protected]") > 0> assert emails.count("[email protected]") > 0
E AssertionError: assert 0 > 0
E + where 0 = <built-in method count of list object at 0x7d9189cec640>('[email protected]')
E + where <built-in method count of list object at 0x7d9189cec640> = ['[email protected]'].countLe titre principal est assert 0 > 0, et la liste qui vous intéresse est enfouie à la fin de la seconde ligne where. La bonne façon, assert "[email protected]" in emails, place la liste dans le titre principal. Écrivez l'assert comme la phrase que vous aviez en tête, et pytest expliquera l'échec dans les mêmes termes.
Truthiness : assert x n'est pas assert x == True
assert x réussit dès lors que x est truthy — n'importe quoi sauf False, None, 0, "" et les conteneurs vides. C'est souvent exactement ce que vous voulez ; parfois, cela masque un bug.
rules.py comporte deux fonctions. overdue est correcte ; is_adult contient un bug — elle renvoie des chaînes au lieu de booléens :
def overdue(invoices):
return [i for i in invoices if i["days"] > 30]
def is_adult(age):
if age >= 18:
return "yes"
return "no"from rules import is_adult, overdue
def test_finds_overdue():
assert overdue([{"id": 1, "days": 12}, {"id": 2, "days": 30}])
def test_child_is_not_adult():
assert not is_adult(12)
def test_adult_truthy():
assert is_adult(40)
def test_adult_is_true():
assert is_adult(40) is Truepytest -q, avec les trois échecs réduits à leurs lignes E :
FF.F [100%]
E AssertionError: assert []
E + where [] = overdue([{'id': 1, 'days': 12}, {'id': 2, 'days': 30}])
E AssertionError: assert not 'no'
E + where 'no' = is_adult(12)
E AssertionError: assert 'yes' is True
E + where 'yes' = is_adult(40)
=========================== short test summary info ============================
FAILED test_rules.py::test_finds_overdue - AssertionError: assert []
FAILED test_rules.py::test_child_is_not_adult - AssertionError: assert not 'no'
FAILED test_rules.py::test_adult_is_true - AssertionError: assert 'yes' is True
3 failed, 1 passed in 0.01sLe premier échec : même un simple assert some_list obtient un rapport utile. assert [] indique que la liste est revenue vide, et non erronée. Trente jours n'est pas strictement supérieur à trente, le code est donc correct et ce sont les données du test qui étaient fausses — ce que l'on apprend d'une seule ligne.
À présent, observez le point dans FF.F. test_adult_truthy a réussi. is_adult renvoie la chaîne "yes", qui est truthy, donc assert is_adult(40) a été satisfait par une fonction buguée. "no" est truthy également, c'est pourquoi assert not is_adult(12) l'a détecté — mais seulement par chance, selon le cas qu'il vous est arrivé de tester.
Alors, assert x == True ? Cela attrape "yes", mais comporte une faille qui lui est propre : en Python, 1 == True et 0 == False. stock.py :
def in_stock(qty):
return qty and qty > 0from stock import in_stock
def test_zero_equals_false():
assert in_stock(0) == False
def test_zero_is_false():
assert in_stock(0) is False.F [100%]
> assert in_stock(0) is False
E assert 0 is False
E + where 0 = in_stock(0)
1 failed, 1 passed in 0.01sqty and qty > 0 renvoie 0 lorsque qty vaut 0, et non False. Le test avec == a réussi ; le test avec is s'en est aperçu. La règle qui en découle :
assert x/assert not xlorsque n'importe quelle valeur truthy ou falsy est acceptable — « la liste n'est pas vide », « il n'y a pas de message d'erreur ».assert x is True/assert x is Falselorsque la fonction promet un vraibool.assert x == Truepresque jamais. C'est plus laxiste queis Trueet moins expressif qu'un simpleassert x; les linters le signalent.
Quand les valeurs sont volumineuses : les diffs
Pour des valeurs courtes, assert 80.0 == 70.0 dit tout ce qu'il y a à savoir. Pour une liste de quarante éléments ou un e-mail de trois lignes, pytest compare les deux côtés et n'affiche que ce qui diffère.
def test_list():
got = ["pen", "notebook", "bag", "eraser"]
assert got == ["pen", "notebook", "ruler", "eraser"]
def test_dict():
got = {"id": 7, "name": "Ada", "role": "admin", "active": True}
assert got == {"id": 7, "name": "Ada", "role": "editor", "active": True}
def test_multiline_string():
got = "Dear Ada,\nYour order 7 has shipped.\nThanks!"
assert got == "Dear Ada,\nYour order 7 has been shipped.\nThanks!"Les lignes E des trois échecs :
E AssertionError: assert ['pen', 'note...ag', 'eraser'] == ['pen', 'note...er', 'eraser']
E
E At index 2 diff: 'bag' != 'ruler'
E Use -v to get more diff
E AssertionError: assert {'id': 7, 'na...active': True} == {'id': 7, 'na...active': True}
E
E Omitting 3 identical items, use -vv to show
E Differing items:
E {'role': 'admin'} != {'role': 'editor'}
E Use -v to get more diff
E AssertionError: assert 'Dear Ada,\nY...ped.\nThanks!' == 'Dear Ada,\nY...ped.\nThanks!'
E
E Dear Ada,
E - Your order 7 has been shipped.
E ? -----
E + Your order 7 has shipped.
E Thanks!La première ligne E est raccourcie avec ... pour tenir sur l'écran ; les lignes situées en dessous apportent les détails :
- Listes — le premier indice où elles diffèrent, ou quel côté contient des éléments en trop.
- Dictionnaires — les clés dont les valeurs diffèrent. Les clés identiques sont comptées, et non affichées.
- Chaînes de caractères — un diff ligne par ligne. Les lignes commençant par deux espaces sont identiques des deux côtés.
+marque le côté gauche de==,-le côté droit. La ligne?en dessous pointe les caractères exacts.
Ainsi, si vous écrivez toujours assert actual == expected, + correspond à ce que votre code a produit et - à ce que vous vouliez. Inversez l'ordre d'un test à l'autre et vous devrez vous arrêter à chaque fois pour comprendre qui est qui ; conservez cet ordre, et vous n'aurez jamais à le faire.
Une longue chaîne sur une seule ligne reçoit le même traitement, la partie identique étant ignorée :
def test_receipt_line():
got = "2026-10-07 | order 1042 | 3 items | paid by card | ships to Dhaka | total 2420.00"
assert got == "2026-10-07 | order 1042 | 3 items | paid by card | ships to Dhaka | total 2402.00"E AssertionError: assert '2026-10-07 |...total 2420.00' == '2026-10-07 |...total 2402.00'
E
E Skipping 66 identical leading characters in diff, use -v to show
E - | total 2402.00
E ? -
E + | total 2420.00
E ? +Deux chiffres inversés à la fin d'une ligne de 80 caractères, trouvés pour vous.
-v et -vv : demander l'intégralité du diff
Remarquez les indications : Use -v to get more diff, use -vv to show. Par défaut, pytest tronque les explications trop longues, et chaque -v augmente le niveau de détail. Le test de dictionnaire avec pytest -vv :
E AssertionError: assert {'id': 7, 'name': 'Ada', 'role': 'admin', 'active': True} == {'id': 7, 'name': 'Ada', 'role': 'editor', 'active': True}
E
E Common items:
E {'active': True, 'id': 7, 'name': 'Ada'}
E Differing items:
E {'role': 'admin'} != {'role': 'editor'}
E
E Full diff:
E {
E 'id': 7,
E 'name': 'Ada',
E - 'role': 'editor',
E ? ^ ^^^
E + 'role': 'admin',
E ? ^ + ^
E 'active': True,
E }Rien n'est raccourci : les deux dictionnaires en entier, les éléments communs et un diff avec une clé par ligne. Pourquoi ne pas toujours utiliser -vv ? Parce que sur une liste de deux cents enregistrements, le diff complet fait deux cents lignes, et le résumé par défaut — « à l'indice 2 » — est ce dont vous aviez besoin. Commencez par le comportement par défaut ; ajoutez -vv pour le seul test dont le résumé ne suffit pas.
Votre propre message : assert cond, "msg"
Après une virgule, un assert peut recevoir un message, que pytest affiche au-dessus de sa propre explication :
def test_everything_in_stock():
stock = {"pen": 12, "bag": 0}
for name, qty in stock.items():
assert qty > 0, f"{name} is out of stock"> assert qty > 0, f"{name} is out of stock"
E AssertionError: bag is out of stock
E assert 0 > 0Sans le message, vous verriez assert 0 > 0 et devriez chercher quel article valait zéro. Un message a toute sa place lorsque les valeurs seules ne précisent pas quel cas a échoué ou pourquoi la règle existe. La mauvaise utilisation consiste à répéter ce que pytest affiche déjà : "expected 70.0 but got 80.0" n'apporte rien par rapport à assert 80.0 == 70.0.
Le piège du tuple
Quand un message allonge la ligne, il est tentant d'entourer le tout de parenthèses :
def test_total():
total = 80.0
assert (total == 70.0, "total is wrong"). [100%]
=============================== warnings summary ===============================
test_trap.py:3
/home/you/shop/test_trap.py:3: PytestAssertRewriteWarning: assertion is always true, perhaps remove parentheses?
assert (total == 70.0, "total is wrong")
-- Docs: https://docs.pytest.org/en/stable/how-to/capture-warnings.html
1 passed, 1 warning in 0.01sLe test a réussi, bien que total vaille 80.0. Des parenthèses contenant une virgule construisent un tuple de deux éléments, et un tuple non vide est toujours truthy — l'assert vérifie le tuple, et non la comparaison. Pytest émet un avertissement, mais un avertissement tout en bas d'une longue exécution passe facilement inaperçu. Retirez les parenthèses :
def test_total():
total = 80.0
assert total == 70.0, "total is wrong"> assert total == 70.0, "total is wrong"
E AssertionError: total is wrong
E assert 80.0 == 70.0Si la ligne est trop longue, placez des parenthèses autour du message uniquement :
def test_total():
total = 80.0
assert total == 70.0, (
"total is wrong after applying the loyalty discount"
)Un assert par comportement
Un test qui vérifie des champs les uns après les autres s'arrête au premier qui échoue. orders.py contient deux bugs — les espaces autour du nom ne sont pas supprimés et la quantité reste une chaîne de caractères :
def parse_order(line):
order_id, customer, qty, price = line.split(",")
return {"id": int(order_id), "customer": customer, "qty": qty, "price": float(price)}La mauvaise manière, puis la bonne :
from orders import parse_order
def test_parse_order_fields():
order = parse_order("1042, Ada ,3,15.0")
assert order["id"] == 1042
assert order["customer"] == "Ada"
assert order["qty"] == 3
assert order["price"] == 15.0
def test_parse_order():
order = parse_order("1042, Ada ,3,15.0")
assert order == {"id": 1042, "customer": "Ada", "qty": 3, "price": 15.0}> assert order["customer"] == "Ada"
E AssertionError: assert ' Ada ' == 'Ada'
E
E - Ada
E + Ada
E ? + +
> assert order == {"id": 1042, "customer": "Ada", "qty": 3, "price": 15.0}
E AssertionError: assert {'id': 1042, ...'price': 15.0} == {'id': 1042, ...'price': 15.0}
E
E Omitting 2 identical items, use -vv to show
E Differing items:
E {'customer': ' Ada '} != {'customer': 'Ada'}
E {'qty': '3'} != {'qty': 3}
E Use -v to get more diffLe premier test signale un seul bug. Vous le corrigez, relancez le test, et rencontrez le second seulement à ce moment-là. Le second test compare l'intégralité du résultat et signale les deux à la fois.
La règle directrice est un comportement par test, et non un assert par test. « L'analyse d'une ligne produit cet enregistrement » est un seul comportement, donc une comparaison de la valeur entière est idéale. Plusieurs asserts conviennent parfaitement lorsqu'ils décrivent ensemble un seul comportement. Ce qu'il faut éviter, c'est un test unique qui vérifie l'analyse, puis l'omission des lignes vides, puis le calcul du total — trois comportements distincts, où le premier échec masque les deux autres et où le nom du test ne peut pas exprimer ce qui s'est cassé.
Quel niveau de détail pour le rapport : --tb et -l
La partie située entre l'en-tête et file:line est la traceback, et --tb choisit la quantité à afficher. Le test de cart.py avec pytest -q --tb=short :
___________________________ test_total_with_discount ___________________________
test_cart.py:10: in test_total_with_discount
assert total(items, 10) == 70.0
E assert 80.0 == 70.0
E + where 80.0 = total([(15.0, 2), (60.0, 1)], 10)Seule la ligne en échec, et non la fonction entière. --tb=line donne un emplacement par échec :
E assert 80.0 == 70.0
+ where 80.0 = total([(15.0, 2), (60.0, 1)], 10)
/home/you/shop/test_cart.py:10: assert 80.0 == 70.0Et --tb=no ne laisse que le résumé :
.F [100%]
=========================== short test summary info ============================
FAILED test_cart.py::test_total_with_discount - assert 80.0 == 70.0
1 failed, 1 passed in 0.01sLe choix par défaut, --tb=auto, est la forme longue que vous avez vue tout au long du chapitre. Utilisez --tb=no ou --tb=line pour voir ce qui a échoué sur une grande exécution ; puis relancez un test avec le format par défaut pour voir pourquoi.
À l'inverse, -l (--showlocals) ajoute les variables locales de la fonction en échec. pytest -q -l :
> assert total(items, 10) == 70.0
E assert 80.0 == 70.0
E + where 80.0 = total([(15.0, 2), (60.0, 1)], 10)
items = [(15.0, 2), (60.0, 1)]
test_cart.py:10: AssertionErrorDans un test comportant une boucle ou plusieurs variables intermédiaires, ce bloc supplémentaire est souvent celui qui explique l'échec — vous le verrez dans la solution à la fin.
Un exemple complet
invoice.py lit des lignes comme "pen, 12, 15.0" :
def parse_line(line):
name, qty, price = line.split(",")
return {"name": name.strip(), "qty": int(qty), "price": float(price)}
def summarise(lines):
items = [parse_line(line) for line in lines if line.strip()]
total = sum(item["qty"] * item["price"] for item in items)
return {
"count": len(items),
"names": [item["name"] for item in items],
"total": round(total, 2),
}
def find_item(items, name):
for item in items:
if item["name"] == name:
return item
return Nonetest_invoice.py reprend le plan de la section « Avant d'écrire le test », une ligne par test, chacun étant écrit avec la comparaison issue de la dernière colonne du tableau :
from invoice import find_item, parse_line, summarise
LINES = ["pen, 12, 15.0", "", "bag, 2, 850.0"]
def test_parse_line_strips_and_converts():
assert parse_line(" pen , 12, 15.0") == {"name": "pen", "qty": 12, "price": 15.0}
def test_blank_lines_are_skipped():
assert summarise(LINES)["count"] == 2
def test_summary():
assert summarise(LINES) == {"count": 2, "names": ["pen", "bag"], "total": 1880.0}
def test_find_item_present():
items = [parse_line("pen, 12, 15.0")]
assert find_item(items, "pen") is not None
def test_find_item_missing():
assert find_item([], "pen") is None
def test_total_is_positive():
total = summarise(LINES)["total"]
assert total > 0, f"total should be positive, got {total}"pytest -q :
...... [100%]
6 passed in 0.01sÀ présent, quelqu'un « nettoie » summarise pour que les noms reviennent triés, en modifiant une ligne en "names": sorted(item["name"] for item in items),. pytest -q --tb=short :
..F... [100%]
=================================== FAILURES ===================================
_________________________________ test_summary _________________________________
test_invoice.py:15: in test_summary
assert summarise(LINES) == {"count": 2, "names": ["pen", "bag"], "total": 1880.0}
E AssertionError: assert {'count': 2, ...otal': 1880.0} == {'count': 2, ...otal': 1880.0}
E
E Omitting 2 identical items, use -vv to show
E Differing items:
E {'names': ['bag', 'pen']} != {'names': ['pen', 'bag']}
E Use -v to get more diff
=========================== short test summary info ============================
FAILED test_invoice.py::test_summary - AssertionError: assert {'count': 2, .....
1 failed, 5 passed in 0.01sLisez-le comme ce chapitre vous l'a appris. Un test sur six a échoué, test_summary, à la ligne 15. La première ligne E est raccourcie, le détail se trouve donc en dessous : deux clés correspondent, et names diffère — ['bag', 'pen'] venant du code (à gauche) contre ['pen', 'bag'] dans le test (à droite). L'ordre a changé, rien d'autre. Savoir s'il s'agit d'un bug ou d'une modification délibérée est désormais une décision liée aux factures, et non une traque dans le code.
Remarquez aussi quels tests n'ont pas échoué. Le compte, les recherches et le total réussissent toujours, le rapport restreint donc le changement à une seule clé d'un seul dictionnaire. C'est tout l'intérêt de n'avoir qu'un seul comportement par test : la répartition des succès et des échecs constitue en elle-même un diagnostic.
En cas de problème
E AssertionError sans aucune valeur, venant d'un module d'aide La réécriture ne couvre que les fichiers de test et conftest.py. Un assert à l'intérieur, disons, de checks.py produit une simple AssertionError. Enregistrez le module avant son importation, tout en haut de conftest.py : pytest.register_assert_rewrite("checks"). Le rapport affiche alors assert 0 > 0 comme d'habitude.
PytestAssertRewriteWarning: assertion is always true, perhaps remove parentheses? Vous avez écrit assert (condition, "message"). Il s'agit d'un tuple, et le test ne peut jamais échouer. Supprimez les parenthèses extérieures ; n'entourez que le message si la ligne est longue.
AssertionError: assert None == ... ou + where None = ... La fonction que vous avez appelée a renvoyé None. En général, elle comporte un chemin sans return, ou bien elle modifie quelque chose sur place et ne renvoie rien — list.sort() en est le cas d'école.
Use -v to get more diff / ...Full output truncated (8 lines hidden), use '-vv' to show Ce n'est pas une erreur — pytest a raccourci l'explication. Relancez ce test précis avec -vv.
Un test qui réussit alors que la valeur est manifestement fausse Cherchez un assert x où x est une chaîne non vide comme "no", un assert x == True avec x égal à 1, ou le piège du tuple. Un test qui n'a jamais échoué n'a jamais prouvé qu'il fonctionnait — cassez le code volontairement une fois et regardez-le passer au rouge.
Étape 4 sur 6 — Prédire
Vérifiez votre compréhension
Le test échoue. Quelle ligne d'explication pytest affiche-t-il sous la première ligne E ?
def test_names():
got = ["pen", "bag"]
assert got == ["pen", "bag", "ink"]- Aassert 2 == 3
- BRight contains one more item: 'ink'
- CAt index 2 diff: 'ink' != None
- DLeft contains one more item: 'ink'
total vaut 80.0, et pourtant ce test réussit. Pourquoi ?
def test_total():
total = 80.0
assert (total == 70.0, "total is wrong")- ALa chaîne du message remplace la condition
- B`80.0 == 70.0` est vrai pour les flottants
- CLes parenthèses créent un tuple de deux éléments, et un tuple non vide est toujours vrai
- Dpytest ignore les asserts qui comportent un message
Vous écrivez toujours assert actual == expected. Dans un diff de chaînes, à quoi correspondent les lignes commençant par + et celles commençant par - ?
- A`+` correspond à la valeur attendue, `-` à ce que le code a produit
- BCela dépend de la valeur qui est la plus longue
- C`+` correspond à ce que le code a produit (côté gauche), `-` à la valeur attendue (côté droit)
- D`+` marque les caractères ajoutés dans les deux valeurs, `-` les caractères supprimés dans les deux
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
Créez grades.py avec quatre fonctions :
letter(score)—"A"à partir de 80,"B"à partir de 65,"C"à partir de 50, sinon"F"passed(score)— un vraibool,Trueà partir de 50report(scores)— prend un dictionnaire associant des noms à des scores, renvoie un dictionnaire associant des noms à des lettresbest(scores)— le nom ayant le score le plus élevé, ouNonepour un dictionnaire vide
Rédigez d'abord le plan de test sous la forme d'un tableau, comme ce chapitre l'a fait : cas, entrée, attendu, et l'assert que vous utiliserez. Écrivez ensuite test_grades.py avec au moins six tests. Utilisez chacun de ceux-ci au moins une fois : == sur un dictionnaire entier, in, is None, is True ou is False, une comparaison comme >=, et un assert avec un message qui nomme le cas en échec.
Ensuite, cassez volontairement des choses et lisez chaque rapport :
- Faites en sorte que
passedrenvoiescore >= 50 and scoreau lieu d'un booléen. Quels tests le remarquent ? - Déplacez la limite du
"B"de 65 à 66. Quels tests le remarquent, lesquels ne le remarquent pas — et pourquoi ? - Entourez l'un de vos asserts de parenthèses en y incluant son message. Trouvez l'avertissement.
- Avec la modification 2 en place, exécutez le fichier avec
--tb=line, puis le test en échec avec-l.
Notez, pour chaque modification, la première ligne du rapport qui vous a indiqué ce qui n'allait pas.
Solution
D'abord le plan. Les limites sont l'endroit où le code d'évaluation se trompe, chaque limite a donc droit à ses deux côtés — 80 et 79, 65 et 64, 50 et 49 :
| Cas | Entrée | Attendu | Assert | | --- | --- | --- | --- | | Chaque limite, des deux côtés | 80, 79, 65, 64, 50, 49 | A, B, B, C, C, F | == avec un message nommant le score | | passed est un vrai booléen | 50, 49 | True, False | is True, is False | | Rapport complet | quatre étudiants | le dictionnaire complet de lettres | == sur le dictionnaire | | Quelqu'un a échoué | les quatre mêmes | "F" parmi les lettres | in | | best est bien le meilleur | les quatre mêmes | aucun score supérieur à celui du vainqueur | >= avec un message | | Personne à classer | {} | None | is None |
grades.py :
def letter(score):
if score >= 80:
return "A"
if score >= 65:
return "B"
if score >= 50:
return "C"
return "F"
def passed(score):
return score >= 50
def report(scores):
return {name: letter(score) for name, score in scores.items()}
def best(scores):
if not scores:
return None
return max(scores, key=scores.get)test_grades.py :
from grades import best, letter, passed, report
SCORES = {"ada": 91, "alan": 64, "grace": 50, "linus": 49}
def test_letter_boundaries():
cases = [(80, "A"), (79, "B"), (65, "B"), (64, "C"), (50, "C"), (49, "F")]
for score, expected in cases:
assert letter(score) == expected, f"letter({score})"
def test_passed_returns_a_real_bool():
assert passed(50) is True
assert passed(49) is False
def test_report():
assert report(SCORES) == {"ada": "A", "alan": "C", "grace": "C", "linus": "F"}
def test_a_failing_letter_appears():
assert "F" in report(SCORES).values()
def test_best_has_the_highest_score():
top = best(SCORES)
for name, score in SCORES.items():
assert SCORES[top] >= score, f"{name} scored more than {top}"
def test_best_of_nobody():
assert best({}) is Nonepytest -q :
...... [100%]
6 passed in 0.01sModification 1 — passed renvoie score >= 50 and score :
.F.... [100%]
> assert passed(50) is True
E assert 50 is True
E + where 50 = passed(50)
FAILED test_grades.py::test_passed_returns_a_real_bool - assert 50 is True
1 failed, 5 passed in 0.01s50 est truthy, donc assert passed(50) l'aurait laissé passer. is True l'a détecté, et assert 50 is True nomme la valeur qui aurait dû être un booléen.
Modification 2 — la limite pour "B" est déplacée à 66, avec --tb=line :
F..... [100%]
=================================== FAILURES ===================================
E AssertionError: letter(65)
assert 'C' == 'B'
- B
+ C
/home/you/grades/test_grades.py:9: AssertionError: letter(65)
=========================== short test summary info ============================
FAILED test_grades.py::test_letter_boundaries - AssertionError: letter(65)
1 failed, 5 passed in 0.01sSeul le test des valeurs limites l'a remarqué. test_report a quand même réussi car aucun étudiant dans SCORES n'a exactement 65 — un test ne couvre que les valeurs que vous y mettez, c'est pourquoi le plan listait les deux côtés de chaque limite. Le message letter(65) vient en premier, et il est nécessaire : la boucle exécute six cas sur la même ligne, et sans lui vous sauriez qu'une lettre est incorrecte mais pas pour quel score. + C correspond à ce que le code a renvoyé, - B à ce que le test attendait.
Le même échec avec pytest -q -l test_grades.py::test_letter_boundaries se termine par les variables de la boucle :
cases = [(80, 'A'), (79, 'B'), (65, 'B'), (64, 'C'), (50, 'C'), (49, 'F')]
expected = 'B'
score = 65Modification 3 — assert (best({}) is None, "no scores, no best") à la ligne 32 :
...... [100%]
/home/you/grades/test_grades.py:32: PytestAssertRewriteWarning: assertion is always true, perhaps remove parentheses?
6 passed, 1 warning in 0.01sSix ont réussi — et l'un d'eux ne peut plus échouer, quoi que renvoie best. L'avertissement en est le seul signe.
Les premières lignes qui vous ont indiqué ce qui n'allait pas : assert 50 is True, AssertionError: letter(65), et assertion is always true. Des lignes comme celles-ci sont ce que vous chercherez en premier lors de chaque échec désormais.
É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