Leçon 08-2 — pytest : premiers tests

1. Objectif

À la fin de cette leçon tu sauras : installer pytest avec pip (ta première installation d'un outil externe — on prend le temps de comprendre ce geste), écrire un fichier test_*.py contenant des fonctions test_* avec assert, lancer la suite avec python -m pytest, et lire son rapport dans les deux cas : les points verts quand tout passe, le F et le rapport d'échec quand quelque chose casse.

2. Pourquoi c'est important

Jusqu'ici, tu vérifiais ton code en le lançant et en regardant l'écran. Ça ne tient pas la distance : à chaque modification, il faudrait re-vérifier À LA MAIN tout ce qui marchait avant. Personne ne le fait — et c'est comme ça que les régressions s'installent (« ça marchait la semaine dernière ! »). Un test automatisé, c'est ta vérification manuelle écrite une fois, rejouable à l'infini en une commande. Tous les projets sérieux — les bibliothèques que tu utiliseras, les logiciels d'entreprise — vivent sous la protection de milliers de tests. C'est aussi la compétence qui change ton rapport au code : tu vas pouvoir modifier sans peur. Et pytest est l'outil standard de l'écosystème Python pour ça.

3. Explication simple

Un test, c'est une fonction qui appelle TON code et vérifie sa réponse :

# test_cart.py
from cart import total_price          # 1. importer la fonction a tester

def test_total_price_two_items():     # 2. une fonction dont le nom commence par test_
    assert total_price([10, 20]) == 30    # 3. verifier : « j'affirme que... »

assert se lit « j'affirme que » : si l'expression est vraie, il ne se passe rien (le test passe) ; si elle est fausse, le test échoue bruyamment.

pytest est le lanceur : la commande python -m pytest cherche tous les fichiers test_*.py, y exécute toutes les fonctions test_*, et affiche un bilan — un point vert par test réussi, un F rouge par échec.

C'est tout. Trois conventions de nommage (test_*.py, test_*, assert) et une commande. La subtilité n'est pas dans la syntaxe : elle est dans le choix de quoi vérifier (leçon 08-3) et dans l'exigence que ton code soit testable — d'où la leçon 08-1 : on ne peut faire assert f(x) == attendu que si f renvoie quelque chose.

4. Explication approfondie

a) pip : ta première installation de paquet. Jusqu'à aujourd'hui, tu n'as utilisé que la bibliothèque standard, livrée avec Python. pytest n'en fait pas partie : il vit sur PyPI (Python Package Index), l'annuaire public des paquets Python — des centaines de milliers de bibliothèques écrites par la communauté. pip est le gestionnaire de paquets livré avec Python : il télécharge un paquet depuis PyPI, l'installe dans ton installation Python, et gère ses dépendances (tu verras pytest arriver avec cinq compagnons — c'est normal, pip installe aussi ce dont pytest a lui-même besoin).

python -m pip install pytest

Pourquoi python -m pip et pas pip tout court ? Pour la même raison qu'on écrit python -m pytest : la forme -m module garantit qu'on utilise le pip du Python qu'on croit — sur une machine avec plusieurs Python installés, le raccourci pip peut pointer ailleurs (voire ne pas exister dans le PATH). Prends le réflexe python -m ... tout de suite, il t'évitera des heures de confusion.

L'installation est par machine, pas par projet : une fois pytest installé, tous tes projets le voient. ⏩ Au niveau 09, tu apprendras les environnements virtuels (venv), qui isolent les paquets projet par projet — indispensable dès qu'on a plusieurs projets aux besoins différents. Pour ce niveau, l'installation globale suffit.

b) Comment pytest trouve tes tests : la découverte. Tu ne dis jamais à pytest quoi lancer. Il applique des conventions : depuis le dossier courant, il descend récursivement, ouvre chaque fichier test_*.py (ou *_test.py), et y collecte chaque fonction dont le nom commence par test_. Conséquence directe : un nom hors convention = un test invisible, sans aucun message d'erreur. C'est l'erreur n°1 des débutants (section 6).

c) assert n'est pas magique — mais pytest le rend bavard. assert est une instruction Python ordinaire : assert condition lève AssertionError si la condition est fausse. Nu, il est peu loquace. La valeur ajoutée de pytest est la réécriture d'assertion : à l'échec, il te montre les valeurs réelles de chaque côté du == (tu le verras dans les exemples : la ligne E assert 30 == 31 avec le détail where 30 = total_price([10, 20])). C'est pour profiter de ce détail qu'on écrit assert f(x) == attendu plutôt que de comparer soi-même et lever une erreur.

d) Chaque test est indépendant. pytest exécute chaque fonction test_* séparément ; si l'une échoue, les suivantes tournent quand même. D'où la règle : un test = une vérification (ou un petit groupe cohérent). Dix petits tests nommés valent mieux qu'un gros test fourre-tout : quand ça casse, le NOM du test en échec te dit déjà ce qui est cassé.

Avertissement honnête — la difficulté de cette leçon est culturelle, pas technique. Écrire assert total_price([10, 20]) == 30 te semblera trivial, voire inutile (« je le SAIS, que ça fait 30 »). Le déclic viendra quand un test attrapera une régression que tes yeux auraient ratée — le mini-projet et la leçon 08-4 sont construits pour provoquer ce moment. Fais confiance au processus : écris les tests même quand ils semblent évidents.

5. Exemples commentés

Exemple 1 — installer pytest et vérifier

python -m pip install pytest

Sortie (raccourcie — les versions exactes chez toi peuvent différer) :

Collecting pytest
  Using cached pytest-9.1.1-py3-none-any.whl.metadata (7.6 kB)
Collecting colorama>=0.4 (from pytest)
  Using cached colorama-0.4.6-py2.py3-none-any.whl.metadata (17 kB)
Collecting iniconfig>=1.0.1 (from pytest)
Collecting packaging>=22 (from pytest)
Collecting pluggy<2,>=1.5 (from pytest)
Collecting pygments>=2.7.2 (from pytest)
Installing collected packages: pygments, pluggy, packaging, iniconfig, colorama, pytest

Successfully installed colorama-0.4.6 iniconfig-2.3.0 packaging-26.2 pluggy-1.6.0 pygments-2.20.0 pytest-9.1.1

Lecture : pip a téléchargé pytest ET ses cinq dépendances depuis PyPI. La dernière ligne est celle qui compte. Vérification :

python -m pytest --version
pytest 9.1.1

Exemple 2 — premier test qui passe

Le module à tester (remarque : fonction pure, elle return — leçon 08-1) :

# cart.py
def total_price(prices):
    return sum(prices)

Le fichier de tests, à côté :

# test_cart.py
from cart import total_price


def test_total_price_two_items():
    assert total_price([10, 20]) == 30


def test_total_price_empty_list():
    assert total_price([]) == 0        # cas limite : panier vide


def test_total_price_one_item():
    assert total_price([7.5]) == 7.5

Lancement, depuis le dossier du projet :

python -m pytest
============================= test session starts =============================
platform win32 -- Python 3.14.3, pytest-9.1.1, pluggy-1.6.0
rootdir: C:\Users\jtron\Claude\learnpython\lessons\level-08-tests-bonnes-pratiques\exercices\mes-reponses\demo-cart
plugins: anyio-4.13.0
collected 3 items

test_cart.py ...                                                         [100%]

============================== 3 passed in 0.02s ==============================

Lecture ligne par ligne : l'en-tête rappelle les versions et le rootdir (le dossier d'où pytest travaille) ; collected 3 items = trois fonctions test_* trouvées ; test_cart.py ... = un point vert par test réussi ; et le bilan 3 passed. Cette ligne de points est ce que tu veux voir après CHAQUE modification de ton code.

Exemple 3 — un test qui échoue, et le rapport qui va avec

Changeons volontairement l'attendu pour voir un échec :

def test_total_price_two_items():
    assert total_price([10, 20]) == 31    # faux, expres
collected 1 item

test_fail_demo.py F                                                      [100%]

================================== FAILURES ===================================
_________________________ test_total_price_two_items __________________________

    def test_total_price_two_items():
>       assert total_price([10, 20]) == 31
E       assert 30 == 31
E        +  where 30 = total_price([10, 20])

test_fail_demo.py:5: AssertionError
=========================== short test summary info ===========================
FAILED test_fail_demo.py::test_total_price_two_items - assert 30 == 31
============================== 1 failed in 0.07s ==============================

Le F remplace le point. Le bloc FAILURES est décortiqué en section 7 — retiens déjà la ligne clé : E assert 30 == 31, avec en dessous d'où vient le 30. pytest a ÉVALUÉ ton assertion et te montre les vraies valeurs.

Exemple 4 — un vrai bug attrapé, avec le diff de chaînes

Une fonction qui fabrique des slugs d'URL, et sa suite de tests :

# slugify.py
def slugify(title):
    return title.lower().replace(" ", "-")
# test_slugify.py
from slugify import slugify


def test_slugify_simple_title():
    assert slugify("Mon Premier Article") == "mon-premier-article"


def test_slugify_strips_outer_spaces():
    assert slugify("  Bonjour  ") == "bonjour"


def test_slugify_empty_string():
    assert slugify("") == ""
collected 3 items

test_slugify.py .F.                                                      [100%]

================================== FAILURES ===================================
______________________ test_slugify_strips_outer_spaces _______________________

    def test_slugify_strips_outer_spaces():
>       assert slugify("  Bonjour  ") == "bonjour"
E       AssertionError: assert '--bonjour--' == 'bonjour'
E         
E         - bonjour
E         + --bonjour--
E         ? ++       ++

test_slugify.py:9: AssertionError
=========================== short test summary info ===========================
FAILED test_slugify.py::test_slugify_strips_outer_spaces - AssertionError: as...
========================= 1 failed, 2 passed in 0.10s =========================

Regarde .F. : les tests 1 et 3 passent, le 2 échoue. Le diff (- attendu, + obtenu, ? qui pointe les différences) montre le bug d'un coup d'œil : les espaces de bord deviennent des tirets — il manque un .strip() dans slugify. Tu viens de voir un test faire son vrai métier : désigner un bug précisément, sans debugger, sans print.

Exemple 5 — le mode verbeux, pour y voir clair

python -m pytest -v
collected 8 items

test_cart.py::test_total_price_two_items PASSED                          [ 12%]
test_cart.py::test_total_price_empty_list PASSED                         [ 25%]
test_cart.py::test_total_price_one_item PASSED                           [ 37%]
test_discount.py::test_apply_discount_nominal PASSED                     [ 50%]
...

============================== 8 passed in 0.03s ==============================

-v remplace les points par une ligne par test, avec son nom complet (fichier::fonction). Utile quand la suite grossit — et c'est là que les noms de tests soignés paient : cette sortie se lit comme la spécification du programme. Autre option utile : python -m pytest test_cart.py pour ne lancer qu'un seul fichier.

6. Erreurs fréquentes

1) Le test invisible : nom hors convention

# check_cart.py            ← mauvais nom de FICHIER (pas test_*.py)
def check_total():         # ← mauvais nom de FONCTION (pas test_*)
    assert 1 + 1 == 2
collected 0 items

============================ no tests ran in 0.02s ============================

Aucune erreur, aucun avertissement : pytest n'a juste rien trouvé. collected 0 items doit déclencher une alarme dans ta tête — de même qu'un collected 3 items quand tu viens d'écrire un 4e test. Vérifie TOUJOURS le nombre collecté. Correction : test_cart.py et def test_total():.

2) assert sans comparaison : le test qui passe pour de mauvaises raisons

def test_total_price():
    assert total_price([10, 20])     # passe si le resultat est « truthy »...

30 est vrai, mais 31 aussi, et -999 aussi : ce test passe quel que soit le bug, sauf si le résultat vaut 0 — et alors il échoue à tort (panier vide !). Correction : assert total_price([10, 20]) == 30. Un assert utile compare à une valeur attendue précise.

3) Appeler la fonction sans rien affirmer

def test_total_price():
    total_price([10, 20])            # « si ca ne plante pas, ca va »

Le test vérifie seulement que la fonction ne lève pas d'exception. Elle peut renvoyer n'importe quoi, il reste vert. Même correction : un assert avec == et une valeur attendue.

4) Tester avec print au lieu d'assert

def test_total_price():
    print(total_price([10, 20]))     # vieux reflexe du niveau 01

Le test passe toujours (rien n'est affirmé) et, bonus piège, pytest capture la sortie : tu ne vois même pas ton print (sauf échec, ou avec l'option -s). Le réflexe à installer : dans un test, print devient assert.

5) Lancer pytest du mauvais dossier

Le retour du piège du niveau 05 et de la leçon 08-1 : lancé depuis tests/ ou depuis un dossier parent, l'import from cart import ... échoue en ModuleNotFoundError (traceback complet en section 7). Règle : toujours python -m pytest depuis la racine du projet. (Détail qui compte : la forme python -m pytest ajoute le dossier courant au chemin des imports — c'est elle qui rend l'import de cart possible ; un pytest nu ne le fait pas toujours.)

6) L'attendu calculé au lieu d'être écrit en dur

def test_total_price():
    assert total_price([10, 20]) == sum([10, 20])   # recopie la formule !

Si total_price a le même bug que ta formule de test, tout est vert. Le test doit comparer à une valeur que tu as calculée de tête (30), pas à du code qui risque de se tromper pareil. Un attendu de test, ça s'écrit en dur.

7. Lire les messages d'erreur

a) Anatomie d'un rapport d'échec. Reprenons l'exemple 3 :

_________________________ test_total_price_two_items __________________________

    def test_total_price_two_items():
>       assert total_price([10, 20]) == 31
E       assert 30 == 31
E        +  where 30 = total_price([10, 20])

test_fail_demo.py:5: AssertionError

Lecture méthodique :

  1. Le titre : le nom du test en échec. Si tes tests sont bien nommés, tu sais déjà QUELLE fonctionnalité est cassée.
  2. Le chevron > : la ligne exacte du test qui a échoué, montrée dans son contexte.
  3. Les lignes E (pour Error) : le cœur du rapport. assert 30 == 31 est ton assertion réécrite avec les valeurs réelles ; la ligne + where 30 = total_price([10, 20]) te dit d'où sort le 30. Tu sais donc : la fonction a renvoyé 30, le test attendait 31.
  4. La dernière ligne : fichier:ligne: AssertionError — où aller pour corriger. Reste à décider QUI a tort : le code… ou le test (ici, le test : 30 est la bonne réponse).

b) L'erreur de collecte : rien n'a tourné du tout. Différente d'un échec de test — pytest n'a même pas pu IMPORTER le fichier :

=================================== ERRORS ====================================
_______________________ ERROR collecting test_tasks.py ________________________
ImportError while importing test module '...\tests\test_tasks.py'.
Hint: make sure your test modules/packages have valid Python names.
Traceback:
test_tasks.py:1: in <module>
    from tasks import add_task
E   ModuleNotFoundError: No module named 'tasks'
=========================== short test summary info ===========================
ERROR test_tasks.py
!!!!!!!!!!!!!!!!!!! Interrupted: 1 error during collection !!!!!!!!!!!!!!!!!!!!
============================== 1 error in 0.14s ===============================

Repères : ERRORS (pas FAILURES), Interrupted, et 0 test lancé. La ligne E finale donne la cause réelle — ici le ModuleNotFoundError du mauvais dossier (leçon 08-1, section 7). Ne cherche pas un bug dans tes assertions quand tu vois ERROR collecting : le problème est en amont, dans l'import ou une faute de syntaxe du fichier. Corrige, relance, et attends de revoir collected N items avec le bon N.

8. Exercices — faciles

Règles : chaque exercice dans un sous-dossier de lessons/level-08-tests-bonnes-pratiques/exercices/mes-reponses/ (ex : ex-08-2-a/), car il faut deux fichiers : le module et son test_*.py. Lance python -m pytest depuis le sous-dossier de l'exercice.

a) Installe pytest (python -m pip install pytest) et vérifie avec python -m pytest --version. Puis crée ex-08-2-a/ avec le cart.py et le test_cart.py de l'exemple 2, tapés à la main, et obtiens tes trois points verts. (Si pip te dit Requirement already satisfied, c'est qu'il était déjà installé — tant mieux, lis quand même la sortie.)

b) Dans ex-08-2-a/, casse volontairement total_price (remplace sum(prices) par sum(prices) + 1). Relance. AVANT de lire le rapport, prédis : combien de F, combien de points ? Puis lis le rapport et explique chaque ligne E. Répare, revérifie, trois points verts.

c) Écris temperature.py avec celsius_to_fahrenheit(celsius) (formule : celsius * 9 / 5 + 32) et un test_temperature.py avec au moins trois tests : 0 °C → 32, 100 °C → 212, -40 °C → -40 (le point où les deux échelles se croisent — calcule-le de tête pour y croire). Suite verte exigée.

9. Exercices — moyens

a) Corrige le bug de slugify trouvé à l'exemple 4 (le .strip() manquant), fais passer les trois tests, puis AJOUTE deux tests qui échouent avec la version actuelle : les espaces doubles ("a b" doit donner "a-b", pas "a--b") et les majuscules accentuées ("Été" doit donner "été" — celui-ci passe peut-être déjà : vérifie au lieu de supposer). Corrige slugify jusqu'à la suite complète verte. Piège : l'erreur fréquente n°6 — tous les attendus en dur.

b) Reprends la fonction purchase_report de la leçon 08-1 (exemple 3) et écris sa suite de tests SANS relancer le programme à la main : panier vide, dépassement, budget exactement atteint (lequel des deux messages sort ? décide en lisant le code, puis vérifie par le test). Minimum quatre tests, noms explicites (test_purchase_report_exact_budget, etc.).

c) À l'envers : voici la suite de tests, écris le module password.py qui la fait passer (c'est le principe du TDD, ⏩ approfondi au mini-projet du niveau) :

# test_password.py
from password import is_strong_password


def test_long_mixed_password_is_strong():
    assert is_strong_password("Abcdef12") is True


def test_short_password_is_weak():
    assert is_strong_password("Ab1") is False


def test_password_without_digit_is_weak():
    assert is_strong_password("Abcdefgh") is False


def test_password_without_letter_is_weak():
    assert is_strong_password("12345678") is False

(Règles à déduire des tests : au moins 8 caractères, au moins un chiffre, au moins une lettre. Note is True / is False : la fonction doit renvoyer un vrai booléen, pas une valeur « truthy ».)

10. Exercices — difficiles

a) Archéologie de rapport : voici une sortie pytest RÉELLE. Sans voir ni le module ni les tests, reconstruis un stats.py et un test_stats.py plausibles qui reproduisent EXACTEMENT cette sortie (mêmes noms, même échec, mêmes valeurs), puis explique quel est le bug le plus probable du module et corrige-le.

collected 1 item

test_fail.py F                                                           [100%]

================================== FAILURES ===================================
______________________ test_total_price_rounds_correctly ______________________

    def test_total_price_rounds_correctly():
>       assert total_price([0.1, 0.2]) == 0.3
E       assert 0.30000000000000004 == 0.3
E        +  where 0.30000000000000004 = total_price([0.1, 0.2])

test_fail.py:5: AssertionError

11. Mini-projet lié — première suite de tests du gestionnaire de tâches

Le moment que la leçon 08-1 préparait : reprends ton gestionnaire de tâches opéré (calcul/affichage séparés, import tasks silencieux) et écris sa première vraie suite de tests, en 30–45 min :

  1. Crée tests/test_tasks.py dans le projet (et lance bien python -m pytest depuis la racine).
  2. Couvre au moins quatre fonctions pures de tasks.py avec au moins huit tests au total. Pour chaque fonction : un cas normal + un cas moins évident (liste vide, tâche déjà terminée, titre inexistant…).
  3. Lance la suite, corrige ce qui doit l'être — si un test révèle un vrai bug, savoure : c'est le déclic annoncé, note-le dans ton journal.
  4. Puis la démonstration finale : casse volontairement une fonction de tasks.py (change un >= en >, inverse un booléen), relance, regarde la suite désigner le coupable, répare. C'est exactement ce que les tests feront pour toi à chaque modification future — sauf que le bug ne sera pas volontaire.

Critère de réussite : suite verte, ET tu es capable de casser n'importe quelle fonction testée et de voir un F apparaître (si un sabotage ne fait rien échouer, cette fonction est mal testée — améliore le test).

12. Correction / méthode de correction

Les solutions détaillées sont dans solutions/serie-08-2-solutions.md.

Méthode : (1) finis TOUS les exercices avant d'ouvrir les solutions ; (2) compare les assertions : as-tu comparé à des valeurs attendues précises, écrites en dur ? (3) compare les noms de tests : les tiens disent-ils ce qui est vérifié ? (4) pour chaque écart, une ligne de journal ; (5) refais de tête l'exercice raté le lendemain.

13. À retenir

14. Questions de révision

  1. Qu'est-ce que PyPI, et quel est le rôle exact de pip ?
  2. Pourquoi préférer python -m pytest à pytest tout court ? (Deux raisons.)
  3. Quelles sont les deux conventions de nommage sans lesquelles pytest ne trouve pas un test ?
  4. Pourquoi assert f(x) sans == est-il un mauvais test ? Donne un cas où il passe à tort ET un cas où il échoue à tort.
  5. Dans un rapport d'échec, que montrent les lignes préfixées E ?
  6. Quelle différence entre un F dans FAILURES et un ERROR collecting ?
  7. Pourquoi ne compare-t-on jamais des flottants avec == dans un test, et quelles sont les deux alternatives ?
  8. Pourquoi l'attendu d'un test doit-il être écrit en dur plutôt que calculé ?

15. Checklist de compréhension

Si une case reste vide : refais les exercices de la section correspondante demain, AVANT d'ouvrir la leçon suivante.

16. Commit conseillé

git add lessons/level-08-tests-bonnes-pratiques/exercices/mes-reponses/
git commit -m "exercises: complete lesson 08-2 first pytest tests"

Puis, si c'est ta fin de session : entrée de journal + git push.