Solutions — série 08-2 (pytest : premiers tests)

Rappel de la règle d'or : on n'ouvre ce fichier qu'après avoir terminé (ou sérieusement séché sur) la série. Compare surtout tes assertions (valeurs attendues en dur ?) et tes noms de tests (disent-ils ce qui est vérifié ?) — c'est là que se joue la qualité d'une suite.


Exercice facile a — installation et premiers points verts

Raisonnement — Trois vérifications en chaîne : pip a fait son travail (Successfully installed ... ou Requirement already satisfied), pytest répond (python -m pytest --version), et la découverte fonctionne (le collected 3 items — À vérifier systématiquement, c'est le compteur qui dit si tes conventions de nommage sont bonnes).

Solution

python -m pip install pytest
python -m pytest --version

Puis ex-08-2-a/cart.py et ex-08-2-a/test_cart.py recopiés de l'exemple 2 de la leçon, et depuis ex-08-2-a/ :

python -m pytest
collected 3 items

test_cart.py ...                                                         [100%]

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

Pourquoi ça marchepython -m pip utilise le pip DU python qui vient de répondre --version — les deux commandes parlent forcément de la même installation. Et pytest collecte 3 items parce que les deux conventions sont respectées : fichier test_*.py, fonctions test_*.

Erreur classique sur cet exercice — Taper pip install pytest (sans python -m) et obtenir soit pip : commande introuvable, soit — pire — une installation dans un AUTRE Python de la machine, suivie d'un No module named pytest incompréhensible. Si ça t'est arrivé : c'est exactement pourquoi la leçon impose la forme python -m.

Variante plus difficile — Tape python -m pip show pytest : tu verras la version, l'emplacement d'installation et les dépendances (Requires:). Compare avec les cinq paquets que pip avait installés — tout s'explique.


Exercice facile b — casser, prédire, lire, réparer

Raisonnement — Le sabotage sum(prices) + 1 décale TOUTES les sorties de +1. Donc les trois assertions deviennent fausses : 31 != 30, 1 != 0 (le piège : sum([]) vaut 0, donc la version sabotée renvoie 1 sur le panier vide), 8.5 != 7.5. Prédiction : trois F, zéro point.

Solution — La sortie confirme :

test_cart.py FFF                                                         [100%]

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

    def test_total_price_two_items():
>       assert total_price([10, 20]) == 30
E       assert 31 == 30
E        +  where 31 = total_price([10, 20])
...
========================= 3 failed in 0.09s =========================

Chaque bloc E se lit pareil : ligne 1, l'assertion réécrite avec les valeurs réelles (assert 31 == 30) ; ligne 2, la provenance de la valeur obtenue (where 31 = total_price([10, 20])). Diagnostic : la fonction renvoie systématiquement 1 de trop → le bug est DANS la fonction, pas dans les tests. On répare, on relance : 3 passed.

Pourquoi ça marche — C'est la démonstration du filet : UNE modification d'UN caractère dans le code a fait hurler trois tests instantanément. Sans suite, ce + 1 (imagine-le moins volontaire : une faute de frappe en pleine modification) serait parti se cacher dans le programme.

Erreur classique sur cet exercice — Prédire deux F au lieu de trois, en pensant que sum([]) + 1 planterait ou vaudrait 0. Non : sum([]) vaut 0 (c'est même LE cas limite que le test du panier vide verrouille), donc la version sabotée renvoie 1. Si tu t'es trompé là-dessus, c'était une prédiction utile : note-la au journal.

Variante plus difficile — Sabote plus finement : sum(prices[1:]) (on oublie le premier article). Quels tests échouent maintenant ? (Réponse : les items [10, 20]20 != 30 : F ; panier vide → sum([]) == 0 : passe ! ; un article → 0 != 7.5 : F.) Moralité : certains bugs passent entre les mailles de certains tests — plus les cas sont variés, plus le filet est serré.


Exercice facile c — celsius_to_fahrenheit

Raisonnement — Trois valeurs de référence connues : 0 °C = 32 °F (congélation), 100 °C = 212 °F (ébullition), et le point de croisement -40 °C = -40 °F. Vérification de tête pour y croire : -40 × 9/5 + 32 = -72 + 32 = -40. Ce sont des attendus EN DUR, calculés hors du code — pas la formule recopiée dans le test.

Solution

# ex-08-2-c/temperature.py
def celsius_to_fahrenheit(celsius):
    return celsius * 9 / 5 + 32
# ex-08-2-c/test_temperature.py
from temperature import celsius_to_fahrenheit


def test_freezing_point():
    assert celsius_to_fahrenheit(0) == 32


def test_boiling_point():
    assert celsius_to_fahrenheit(100) == 212


def test_crossing_point():
    assert celsius_to_fahrenheit(-40) == -40
collected 3 items

test_temperature.py ...                                                  [100%]

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

Pourquoi ça marche — Chaque test compare à une valeur de référence EXTERNE au code (la physique, pas la formule). Détail qui a le droit de t'interpeller : la fonction renvoie 32.0 (un float, à cause du / 5) et le test compare à 32 (un int) — or 32.0 == 32 est True en Python (leçon 01-2), donc tout passe. Le == entre int et float exact est fiable ; c'est le == entre RÉSULTATS de calculs flottants qui ne l'est pas (exercice difficile).

Erreur classique sur cet exercice — L'attendu calculé : assert celsius_to_fahrenheit(100) == 100 * 9 / 5 + 32. Le test recopie la formule — si la formule est fausse dans le code, elle est fausse pareil dans le test : vert garanti, bug garanti. Les attendus d'un test viennent de l'extérieur (énoncé, calcul de tête, source de confiance).

Variante plus difficile — Écris fahrenheit_to_celsius et un test « aller-retour » : fahrenheit_to_celsius(celsius_to_fahrenheit(25)) == 25. Ce style de test (une propriété plutôt qu'une valeur) complète les valeurs de référence — mais ne les remplace pas : les DEUX fonctions pourraient être fausses de façon symétrique.


Exercice moyen a — réparer et étendre slugify

Raisonnement — Trois exigences : bords nettoyés (" Bonjour ""bonjour"), espaces multiples réduits ("a b""a-b"), minuscules accentuées. Plutôt que d'empiler strip() + replace() + des cas spéciaux, un outil du niveau 03 fait les deux premières d'un coup : split() SANS argument découpe sur les espaces en les avalant tous (bords compris), et "-".join(...) recolle avec un seul tiret.

Solution

# ex-08-2-d/slugify.py
def slugify(title):
    return "-".join(title.lower().split())
# ex-08-2-d/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("") == ""


def test_slugify_collapses_double_spaces():
    assert slugify("a  b") == "a-b"


def test_slugify_lowercases_accented_letters():
    assert slugify("Été") == "été"
collected 5 items

test_slugify.py .....                                                    [100%]

============================== 5 passed in 0.02s ==============================

Pourquoi ça marche" Bonjour ".split()["Bonjour"] (bords avalés) ; "a b".split()["a", "b"] (le double espace ne crée PAS d'élément vide) ; "".split()[] et "-".join([])"" (le cas vide traverse proprement). Quant à "Été".lower()"été" : le test des accents passait DÉJÀ avec l'ancienne version — c'est pour ça que l'énoncé disait « vérifie au lieu de supposer ». Un test qui naît vert est quand même utile : il VERROUILLE un comportement qu'on croyait vrai sans preuve.

Erreur classique sur cet exercice — La rustine qui colle à UN test : title.strip().lower().replace(" ", " ").replace(" ", "-") — passe les cinq tests… mais "a b" (TROIS espaces) donne "a--b". Corriger cas par cas produit du code fragile ; chercher l'outil qui traite la FAMILLE de cas (split()/join()) produit du code robuste. Ajoute test_slugify_collapses_triple_spaces si tu veux départager les deux.

Variante plus difficile — Et slugify("Python & Git !") ? La version actuelle garde & et ! dans le slug. C'est l'exercice moyen c de la leçon 08-3 — tu peux y goûter dès maintenant : test rouge d'abord.


Exercice moyen b — la suite de purchase_report

Raisonnement — Quatre chemins dans le code : panier vide, dépassement, budget non atteint, et… budget PILE atteint. Pour ce dernier, lire le code AVANT le test : overrun > 0 est strictement supérieur — donc overrun == 0 prend la branche « OK ». Prédiction : "OK, reste 0.00 EUR". Le test vérifiera la prédiction.

Solution

# ex-08-2-e/test_report.py
from report import purchase_report


def test_purchase_report_empty_cart():
    assert purchase_report([], 30) == "Panier vide !"


def test_purchase_report_over_budget():
    assert purchase_report([10.0, 25.5], 30.0) == "Depassement : 5.50 EUR"


def test_purchase_report_under_budget():
    assert purchase_report([10, 15], 30) == "OK, reste 5.00 EUR"


def test_purchase_report_exact_budget():
    assert purchase_report([10, 20], 30) == "OK, reste 0.00 EUR"
collected 4 items

test_report.py ....                                                      [100%]

============================== 4 passed in 0.02s ==============================

Pourquoi ça marche — Chaque test verrouille UN chemin du if/elif implicite, avec le message EXACT en dur (f-string, :.2f compris). Le test du budget exact est le plus précieux : il grave la décision « pile au budget = OK » — si quelqu'un change > en >=, un test devient rouge et force la discussion.

Erreur classique sur cet exercice — Écrire le test du budget exact avec des FLOTTANTS : purchase_report([10.0, 20.0], 30.0) renvoie… "OK, reste -0.00 EUR" ! Explication : 30.0 - 30.0 vaut 0.0, et -0.0 existe chez les flottants ; formaté en :.2f, il s'affiche -0.00. Avec des entiers, -0 redevient 0"0.00". Ce n'est pas une erreur de ta part — c'est un vrai bug d'affichage du code testé, DÉCOUVERT par le test (bienvenue dans le métier). Le correctif propre dans purchase_report : remaining = abs(overrun) ou overrun = round(overrun, 2) + 0.0 — ou plus simplement if overrun >= 0 inversé pour calculer budget - total positif. Bug trouvé = test ajouté (leçon 08-3) : garde un test flottant qui verrouille le correctif.

Variante plus difficile — Justement : ajoute test_purchase_report_exact_budget_floats avec [10.0, 20.0], 30.0, vois-le rouge, corrige purchase_report, vois tout vert. Tu viens de dérouler le cycle complet de la leçon 08-3 en avance.


Exercice moyen c — écrire le code d'après les tests

Raisonnement — Lire les tests comme un cahier des charges : au moins 8 caractères, au moins un chiffre, au moins une lettre, et retour True/False STRICT (les is True/is False l'exigent). Pour « au moins un chiffre » : parcourir la chaîne et tester char.isdigit() (niveau 02/03) ; pareil avec char.isalpha().

Solution

# ex-08-2-f/password.py
MIN_LENGTH = 8


def is_strong_password(password):
    if len(password) < MIN_LENGTH:
        return False
    has_digit = False
    has_letter = False
    for char in password:
        if char.isdigit():
            has_digit = True
        if char.isalpha():
            has_letter = True
    return has_digit and has_letter
collected 4 items

test_password.py ....                                                    [100%]

============================== 4 passed in 0.02s ==============================

Pourquoi ça marche — Les trois règles sont trois barrières indépendantes ; la longueur d'abord (sortie rapide), puis un seul parcours qui lève les deux drapeaux. has_digit and has_letter entre deux booléens renvoie un booléen — le is True du test passe. (Version plus dense pour plus tard : any(char.isdigit() for char in password) — ⏩ les expressions génératrices arrivent avec l'écriture idiomatique, niveau 09/10.)

Erreur classique sur cet exercice — Renvoyer une valeur « truthy » au lieu d'un booléen :

return len(password) >= 8 and sum(char.isdigit() for char in password)

Ce sum(...) vaut 2 pour "Abcdef12" — et 2 == True est faux, 2 is True encore plus. Le test is True échoue exprès sur ce genre de retour : une fonction is_* renvoie un vrai booléen, c'est son contrat de nommage.

Variante plus difficile — Ajoute une règle « au moins un caractère spécial » en TDD strict : écris les deux tests d'abord (un mot de passe qui devient faible, un qui reste fort), regarde-les rouges, puis code. Indice : not char.isalnum() définit « spécial » simplement.


Exercice difficile a — archéologie de rapport

Raisonnement — Le rapport donne tout : le fichier (test_fail.py, ligne 5), le nom du test, l'assertion exacte, et les valeurs. Reconstruire est mécanique. L'intérêt est ailleurs : comprendre POURQUOI 0.30000000000000004 — et reconnaître que ce n'est pas un bug de la fonction, mais la nature des flottants (leçon 01-2 : 0.1 et 0.2 n'ont pas de représentation binaire exacte, leurs erreurs s'additionnent).

Solution

# ex-08-2-g/stats.py
def total_price(prices):
    return sum(prices)          # aucune erreur ici !
# ex-08-2-g/test_stats.py — version corrigee
import pytest

from stats import total_price


def test_total_price_rounds_correctly():
    assert total_price([0.1, 0.2]) == pytest.approx(0.3)
collected 1 item

test_stats.py .                                                          [100%]

============================== 1 passed in 0.03s ==============================

Pourquoi ça marchepytest.approx(0.3) compare avec une tolérance relative minuscule (par défaut une part par million) : 0.30000000000000004 est « égal à 0.3 » à cette tolérance près, ce qui est exactement le bon verdict pour des flottants. L'alternative round(total_price([0.1, 0.2]), 2) == 0.3 marche aussi, mais déplace la comparaison ; approx dit l'intention directement.

Erreur classique sur cet exercice — « Corriger » la FONCTION en lui faisant faire round(sum(prices), 2). C'est traiter un problème de TEST en mutilant le code : arrondir dans total_price détruit de l'information pour tous les appelants (un total intermédiaire de calcul n'a pas à être arrondi — seul l'AFFICHAGE arrondit, leçon 08-1). Le défaut était dans le == strict du test, pas dans la somme.

Variante plus difficile — Écris un test qui prouve la règle : `assert 0.1

== strict interdit entre résultats flottants dans les tests — pytest.approx toujours. Tu la ressortiras au niveau 09 avec les données réelles (prix, taux, mesures).


Mini-projet — première suite de tests du gestionnaire de tâches

La suite dépend de TON tasks.py, mais voici la grille de correction et un extrait représentatif de ce qui est attendu :

# tests/test_tasks.py — extrait type
from tasks import add_task, count_done, format_task_line


def test_add_task_appends_new_task():
    tasks = add_task([], "buy milk")
    assert tasks == [{"title": "buy milk", "done": False}]


def test_add_task_new_task_is_not_done():
    tasks = add_task([], "buy milk")
    assert tasks[0]["done"] is False


def test_count_done_empty_list():
    assert count_done([]) == 0


def test_count_done_mixed_tasks():
    tasks = [{"title": "a", "done": True}, {"title": "b", "done": False}]
    assert count_done(tasks) == 1


def test_format_task_line_pending_task():
    assert format_task_line({"title": "buy milk", "done": False}) == "[ ] buy milk"


def test_format_task_line_done_task():
    assert format_task_line({"title": "buy milk", "done": True}) == "[x] buy milk"

Les critères, un par un :

Erreur classique — Tester les fonctions d'affichage ou main() en premier, parce que « c'est là qu'on voit le programme ». Elles sont les plus dures à tester (elles ne renvoient rien) et les moins rentables (pas de logique). On teste le CŒUR ; l'interface se vérifie à la main pour l'instant.

Variante plus difficile — Si tes fonctions de persistance prennent le chemin du fichier en paramètre (load_tasks(path)), écris un test qui sauvegarde puis recharge dans un fichier temporaire et compare (save_tasks("test_data.json", tasks) puis assert load_tasks("test_data.json") == tasks). Pense à supprimer le fichier à la fin du test — pytest a des outils propres pour ça (tmp_path), ⏩ tu les rencontreras en approfondissant pytest au niveau 09.