Solutions — série 08-3 (cas limites et tests d'exceptions)

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. Pour l'exercice papier (a), compare ta LISTE de cas à celle du corrigé : chaque cas raté vaut de l'or — note-le au journal. Et un contrat différent du corrigé peut être valable, s'il est décidé, cohérent et testé.


Exercice facile a — les grilles de cas, sur papier

Raisonnement — Dérouler mécaniquement les cinq questions (nominal / limites / vide / extrêmes / invalide) pour chaque fonction, en notant à chaque fois la valeur EXACTE d'entrée et le résultat attendu. Quand le résultat attendu n'est pas évident (« que doit faire last_item([]) ? »), c'est qu'on vient de découvrir un trou de contrat — c'est le but de l'exercice.

Solution

count_words(text) :

FamilleEntréeAttendu
nominal"bonjour le monde"3
limite"mot" (un seul mot)1
vide""0
limite sournoise" " (que des espaces)0 (pas 1 !)
extrême10 000 mots10000 (et vite)
invalidecount_words(42)à décider : TypeError

last_item(items) :

FamilleEntréeAttendu
nominal[1, 2, 3]3
limite[7]7
vide[]à décider : ValueError recommandé (l'IndexError de items[-1] serait un accident)
extrêmeliste de 100 000 élémentsle dernier
nominal 2["a", "b"] (types variés)"b"

grade_to_mention(grade) (admis à partir de 10) :

FamilleEntréeAttendu
nominal13"assez bien"
frontières10, 12, 14, 16"passable", "assez bien", "bien", "tres bien"
juste sous un seuil9.9"insuffisant"
limites du domaine0, 20"insuffisant", "tres bien"
invalide-1, 21ValueError

Pourquoi ça marche — La grille transforme « je crois que j'ai fait le tour » en checklist finie. Remarque que CHAQUE fonction a produit au moins une case « à décider » : c'est la norme, pas l'exception — les contrats des cas limites ne tombent pas du ciel, on les choisit.

Erreur classique sur cet exercice — Deux pièges symétriques : ne lister que des nominaux déguisés ([1, 2, 3] puis [4, 5, 6] — même famille, zéro information nouvelle), et oublier la limite « sournoise » propre au domaine (" " pour du texte, la liste à UN élément pour les listes, la valeur PILE sur le seuil pour les barèmes).

Variante plus difficile — Fais la grille de apply_discount(price, percent) en te concentrant sur price (le paramètre que la leçon a ignoré !) : prix négatif ? prix nul ? Décide d'un contrat. Tu constateras que la version de la leçon accepte apply_discount(-100, 20) sans broncher — trou de contrat authentique.


Exercice facile b — la suite d'apply_discount, et sa destruction

Raisonnement — Partie 1 : recopier en tapant (la mémoire des doigts compte pour la syntaxe with pytest.raises(...)). Partie 2 : le sabotage — supprimer la validation et OBSERVER quels tests protègent quoi.

Solution — La suite verte d'abord (5 tests, exemple 1 de la leçon). Puis, validation supprimée :

test_discount.py ...FF                                                   [100%]

================================== FAILURES ===================================
________________ test_apply_discount_negative_percent_raises __________________

    def test_apply_discount_negative_percent_raises():
>       with pytest.raises(ValueError):
             ^^^^^^^^^^^^^^^^^^^^^^^^^
E       Failed: DID NOT RAISE ValueError
...
========================= 2 failed, 3 passed in 0.09s =========================

Lecture : les trois tests de VALEURS passent encore (la formule est intacte), seuls les deux tests d'EXCEPTIONS hurlent. La suite localise le dégât avec une précision chirurgicale : « le calcul va bien, ce sont tes gardes-fous qui ont disparu ».

Pourquoi ça marchepytest.raises transforme une absence (rien n'a été levé) en échec visible. Sans ces deux tests, la suppression de la validation laissait une suite 100 % verte — et apply_discount(100, -5) rendait tranquillement 105.0 en production.

Erreur classique sur cet exercice — Après le sabotage, s'étonner que « seulement » 2 tests sur 5 échouent et croire la suite défaillante. Non : c'est la granularité voulue (un cas = un test). Une suite où TOUT échoue à la moindre modification serait inutilisable — on veut que le motif des F désigne le coupable.

Variante plus difficile — Deuxième sabotage : garde la validation mais change la formule en price * (1 - percent) / 100. Prédis le motif de F avant de lancer. (Réponse : les trois tests de valeurs échouent, les deux raises passent — le motif exactement inverse.)


Exercice facile c — le test cassé

Raisonnement — Relire la règle : dans un bloc with pytest.raises, tout ce qui suit la ligne qui lève est du code MORT. Ici, add_task([], "") lève (si la validation existe) → les trois lignes suivantes ne s'exécutent JAMAIS. Le test « passe » en ne vérifiant qu'un quart de ce qu'il prétend. Pire : si la validation DISPARAÎT, add_task([], "") ne lève plus, la suite du bloc s'exécute… et comme add_task([], "ok") ne lève pas non plus, le with se termine sans exception → DID NOT RAISE quand même. Le défaut est donc : deux tests distincts écrasés l'un sur l'autre.

Solution

# ex-08-3-c/test_tasks.py — deux tests, deux responsabilites
import pytest

from tasks import add_task


def test_add_task_empty_title_raises():
    with pytest.raises(ValueError):
        add_task([], "")                 # SEULE ligne dans le with


def test_add_task_valid_title_appends():
    tasks = add_task([], "ok")
    assert len(tasks) == 1
    assert tasks[0]["title"] == "ok"

Démonstration finale : sabote add_task (supprime la validation du titre vide) → test_add_task_empty_title_raises échoue en DID NOT RAISE, alors que la version originale de l'exercice… échouait aussi, mais en masquant les asserts du dessous depuis le début. Sabote maintenant l'ajout (tasks.append supprimé) → seul test_add_task_valid_title_appends rougit. Chaque test surveille SA propriété.

Pourquoi ça marche — Un bloc with pytest.raises a une sémantique « ce bloc DOIT lever » ; on ne peut rien vérifier d'autre dedans. Séparer donne à chaque comportement son détecteur indépendant — et des noms qui disent, en cas d'échec, ce qui est cassé.

Erreur classique sur cet exercice — « Corriger » en déplaçant les asserts APRÈS le with mais dans le même test. C'est mieux (le code n'est plus mort), mais le test vérifie toujours deux comportements sans lien — au premier échec, on perd l'information sur le second. Un cas = un test reste la règle.

Variante plus difficile — Écris le test du titre « blanc » (" "). S'il est rouge : add_task valide title == "" au lieu de not title.strip() — corrige (bug trouvé = test ajouté, le test reste).


Exercice moyen a — average et son double contrat

Raisonnement — Deux extensions de contrat, deux exceptions DIFFÉRENTES : liste vide → ValueError (la demande n'a pas de sens), valeur non numérique → TypeError (le TYPE de l'entrée est mauvais — c'est la convention Python, la même que len(42)). Tests d'abord (rouges), code ensuite. Et le message du TypeError doit contenir la valeur fautive : le test le verrouille avec match=.

Solution

# ex-08-3-d/average.py
def average(values):
    """Return the arithmetic mean of values.

    Raises:
        ValueError: if values is empty.
        TypeError: if any element is not a number.
    """
    if not values:
        raise ValueError("cannot average an empty list")
    for value in values:
        if not isinstance(value, (int, float)):     # isinstance : ⏩ vu au niveau 06
            raise TypeError(f"cannot average non-numeric value: {value!r}")
    return sum(values) / len(values)
# ex-08-3-d/test_average.py
import pytest

from average import average


def test_average_nominal():
    assert average([10, 20]) == 15


def test_average_single_value():
    assert average([7]) == 7


def test_average_negative_values():
    assert average([-10, 10]) == 0


def test_average_empty_list_raises():
    with pytest.raises(ValueError, match="empty"):
        average([])


def test_average_string_element_raises():
    with pytest.raises(TypeError, match="'2'"):
        average([1, "2", 3])
collected 5 items

test_average.py .....                                                    [100%]

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

Pourquoi ça marche — La boucle de validation passe AVANT le calcul : on refuse explicitement, avec un message qui montre la valeur fautive ({value!r} affiche '2' avec ses guillemets — précieux pour distinguer la chaîne "2" du nombre 2). Et pourquoi pas un seul test avec pytest.raises(Exception) ? Parce qu'il ne distinguerait pas les deux refus : une régression qui transformerait le TypeError en ValueError (ou en ZeroDivisionError accidentel !) resterait verte. Deux contrats, deux types, deux tests.

Erreur classique sur cet exercice — Valider avec type(value) != int : rejette les float (et average([1.5, 2.5]) est parfaitement légitime). L'autre subtilité, à connaître : isinstance(True, int) vaut True en Python (les booléens SONT des entiers), donc average([True, True]) renvoie 1.0 sans broncher. Acceptable ? À toi de décider — mais maintenant tu le sais, au lieu de le subir.

Variante plus difficile — Justement : décide que les booléens sont refusés (TypeError), écris le test rouge, puis corrige la validation (indice : tester isinstance(value, bool) AVANT le test int/float, car l'ordre des vérifications compte).


Exercice moyen b — grade_to_mention, la suite avant le code

Raisonnement — Compter les comparaisons du futur code : quatre seuils de mention + deux bornes de domaine = le nombre de tests se déduit AVANT d'écrire la fonction. Frontières exactes (10, 12, 14, 16), bornes (0, 20), un nominal par mention, deux invalides avec match=. Écrire les tests d'abord force à fixer le barème noir sur blanc — plus moyen de tergiverser sur « 14, c'est bien ou assez bien ? » pendant qu'on code.

Solution

# ex-08-3-e/grades.py
def grade_to_mention(grade):
    if not 0 <= grade <= 20:
        raise ValueError(f"grade must be between 0 and 20, got {grade}")
    if grade >= 16:
        return "tres bien"
    if grade >= 14:
        return "bien"
    if grade >= 12:
        return "assez bien"
    if grade >= 10:
        return "passable"
    return "insuffisant"
# ex-08-3-e/test_grades.py
import pytest

from grades import grade_to_mention


# --- frontieres exactes des mentions ---------------------------------------
def test_grade_16_is_tres_bien():
    assert grade_to_mention(16) == "tres bien"


def test_grade_14_is_bien():
    assert grade_to_mention(14) == "bien"


def test_grade_12_is_assez_bien():
    assert grade_to_mention(12) == "assez bien"


def test_grade_10_is_passable():
    assert grade_to_mention(10) == "passable"


def test_grade_just_below_10_is_insuffisant():
    assert grade_to_mention(9.9) == "insuffisant"


# --- bornes du domaine ------------------------------------------------------
def test_grade_0_is_insuffisant():
    assert grade_to_mention(0) == "insuffisant"


def test_grade_20_is_tres_bien():
    assert grade_to_mention(20) == "tres bien"


# --- nominaux dans chaque tranche -------------------------------------------
def test_grade_17_is_tres_bien():
    assert grade_to_mention(17) == "tres bien"


def test_grade_13_is_assez_bien():
    assert grade_to_mention(13) == "assez bien"


# --- invalides ----------------------------------------------------------------
def test_negative_grade_raises():
    with pytest.raises(ValueError, match="between 0 and 20"):
        grade_to_mention(-1)


def test_grade_over_20_raises():
    with pytest.raises(ValueError, match="between 0 and 20"):
        grade_to_mention(21)
collected 11 items

test_grades.py ...........                                               [100%]

============================= 11 passed in 0.05s ==============================

Pourquoi ça marche — Les seuils sont testés en ordre DESCENDANT dans le code (>= 16 d'abord) : chaque if n'est atteint que si les tranches supérieures ont été écartées. Les tests de frontières exactes sont ceux qui distinguent >= de > : change grade >= 14 en grade > 14 et SEUL test_grade_14_is_bien rougit — précision chirurgicale, encore.

Erreur classique sur cet exercice — Écrire les if en ordre ASCENDANT (if grade >= 10 d'abord) : toute note ≥ 10 devient « passable », et QUATRE tests rougissent d'un coup (16 → passable, etc.). Si tu l'as fait : c'est le bug de l'exemple 2 de la leçon 08-4, tu as un épisode d'avance. L'autre classique : oublier 9.9 et ne tester que des entiers — le barème accepte les demi-points dans la vraie vie.

Variante plus difficile — Le client change le barème : mention « félicitations » à partir de 18. Combien de tests dois-tu AJOUTER, et lesquels des ANCIENS doivent changer ? (Réponse : ajouter 18-exact et un nominal 19 ; modifier test_grade_20_is_tres_bien qui devient « félicitations » ; tout le reste survit tel quel — c'est la robustesse d'une suite à granularité fine.)


Exercice moyen c — bug trouvé = test ajouté sur slugify

Raisonnement — Procédure stricte, dans l'ordre : (1) le test qui reproduit — il doit être ROUGE (sinon on n'a pas capturé le bug) ; (2) la correction — garder uniquement lettres, chiffres et tirets ; (3) vert ; (4) journal. Pour la correction : filtrer chaque mot caractère par caractère avec char.isalnum() (niveau 03), et ne garder que les mots non vides après filtrage (sinon "Python & Git" produirait "python--git").

Solution

# 1. Le test rouge d'abord
def test_slugify_drops_special_characters():
    assert slugify("Python & Git") == "python-git"
E       AssertionError: assert 'python-&-git' == 'python-git'
# 2. La correction dans slugify.py
def slugify(title):
    cleaned_words = []
    for word in title.lower().split():
        kept = ""
        for char in word:
            if char.isalnum():
                kept += char
        if kept:                        # "&" filtre a vide : on le saute
            cleaned_words.append(kept)
    return "-".join(cleaned_words)

3\. Toute la suite (les 5 anciens tests + le nouveau) : verte. Le nouveau test RESTE — c'est la pierre tombale de ce bug.

Pourquoi ça marche — Le if kept: est le détail qui sépare la bonne correction de la rustine : un mot entièrement composé de caractères spéciaux ("&") disparaît proprement au lieu de laisser un double tiret. Vérifie : slugify("C'est l'ete !")"cest-lete" (les apostrophes tombent, le ! isolé aussi).

Erreur classique sur cet exercice — Corriger AVANT d'écrire le test (l'ordre a l'air pédant, il ne l'est pas) : sans le rouge initial, tu ne sais pas si ton test détecte vraiment le bug — il serait peut-être passé avec l'ancienne version aussi (comme le test des accents de la série 08-2 !). Le rouge initial est la preuve que le détecteur détecte.

Variante plus difficile — Les accents : veux-tu "été" ou "ete" dans un slug d'URL ? Les deux se défendent ; si tu choisis "ete", la traduction lettre à lettre (ée) se fait avec str.replace enchaînés ou une table de correspondance (dict, niveau 03). Test d'abord, comme toujours.


Exercice difficile a — la chasse aux bugs de median

Raisonnement — Grille d'abord, sans lire le code de trop près : nominal impair ([1, 3, 2]2 — trié !), nominal PAIR ([1, 2, 3, 4]2.5, la moyenne des deux du milieu, définition mathématique), un seul élément ([7]7), vide (→ à décider : ValueError), négatifs, doublons. En déroulant ces tests sur la version fournie : le cas pair rend 3 (faux) et le cas vide crashe en IndexError (accident, pas contrat). Deux défauts trouvés PAR la grille — mission de l'énoncé accomplie.

Solution

# ex-08-3-f/stats.py — version corrigee
def median(values):
    """Return the median of values.

    For an even count, return the mean of the two middle values.

    Raises:
        ValueError: if values is empty.
    """
    if not values:
        raise ValueError("cannot compute the median of an empty list")
    ordered = sorted(values)
    middle = len(ordered) // 2
    if len(ordered) % 2 == 1:
        return ordered[middle]
    return (ordered[middle - 1] + ordered[middle]) / 2
# ex-08-3-f/test_stats.py
import pytest

from stats import median


def test_median_odd_count():
    assert median([1, 3, 2]) == 2


def test_median_even_count():
    assert median([1, 2, 3, 4]) == 2.5


def test_median_single_value():
    assert median([7]) == 7


def test_median_two_values():
    assert median([1, 2]) == 1.5


def test_median_negative_values():
    assert median([-10, 10, 0]) == 0


def test_median_unsorted_input():
    assert median([9, 1, 5]) == 5


def test_median_empty_list_raises():
    with pytest.raises(ValueError, match="empty"):
        median([])
collected 7 items

test_stats.py .......                                                    [100%]

============================== 7 passed in 0.02s ==============================

Pourquoi ça marche — Défaut 1 (cas pair) : avec 4 éléments, middle = 2 pointe le TROISIÈME — la version naïve renvoyait un élément qui n'est pas la médiane. La correction moyenne ordered[1] et ordered[2]. Défaut 2 (vide) : ordered[0] sur une liste vide levait IndexError — un accident d'implémentation ; le contrat explicite (ValueError + message) le remplace. Note le test unsorted_input : il verrouille le sorted(...), qu'une « optimisation » future pourrait être tentée de supprimer.

Erreur classique sur cet exercice — Ne tester le cas pair qu'avec des valeurs où le bug est INVISIBLE : median([1, 2, 2, 3]) rend 2 avec la version fausse (ordered[2] = 2) ET la vraie médiane est 2. Un test peut viser le bon cas limite et rater le bug quand même — choisis des valeurs qui DISCRIMINENT ([1, 2, 3, 4] : 3 contre 2.5, impossible à confondre).

Variante plus difficilemedian([1, "2"]) : que se passe-t-il ? (TypeError dans sorted, avec un message qui parle de < entre str et int — un accident, encore.) Applique la démarche complète : décider, valider, tester. Puis compare ta validation avec celle d'average (exercice moyen a) — peux-tu extraire une fonction commune ensure_all_numeric(values) ? C'est un doublon, et la leçon 08-4 t'attend.


Mini-projet — durcir le gestionnaire de tâches

Le résultat dépend de ton code, mais le protocole a des critères vérifiables. Voici la trame d'un audit réussi et les contrats types :

1. L'audit (extrait type, dans le journal ou en commentaire) :

add_task(tasks, title)      : titre vide ? blanc ? -> ValueError [MANQUE test]
complete_task(tasks, index) : index hors bornes ? -> ValueError  [MANQUE]
                              tache deja faite ?  -> accepte, idempotent [A DECIDER + test]
delete_task(tasks, index)   : index hors bornes ? -> ValueError  [MANQUE]
count_done(tasks)           : liste vide -> 0                    [deja teste 08-2]
load_tasks(path)            : fichier absent -> liste vide (1er lancement) [MANQUE test]

2. Les contrats codés (exemple type pour l'index) :

def complete_task(tasks, index):
    if not 0 <= index < len(tasks):
        raise ValueError(f"no task at index {index}")
    tasks[index]["done"] = True
    return tasks

3. Les tests des contrats (chaque raise a son pytest.raises) :

def test_complete_task_bad_index_raises():
    with pytest.raises(ValueError, match="no task at index 5"):
        complete_task([{"title": "a", "done": False}], 5)


def test_complete_task_negative_index_raises():
    with pytest.raises(ValueError, match="no task at index -1"):
        complete_task([{"title": "a", "done": False}], -1)

(Remarque le second : sans la validation, tasks[-1] aurait « marché » en modifiant la DERNIÈRE tâche — le bug le plus sournois du projet, car silencieux. Si ton audit l'avait raté, note-le : c'est un piège de niveau 03 qui ne se voit QUE par la grille des limites.)

4. L'interface : dans main.py, chaque appel à une fonction qui peut lever est enveloppé :

try:
    complete_task(tasks, chosen_index)
except ValueError as error:
    print(f"Erreur : {error}")

Critères de réussite, vérifiables un par un — la suite a environ doublé ; python -m pytest -v liste au moins un test *_raises par fonction qui valide ; et la torture manuelle du menu (index 99, index -1, titre vide, lettres à la place d'un nombre) n'affiche QUE des messages propres — zéro traceback.

Erreur classique — Attraper large dans main.py (except Exception) : les VRAIS bugs (un KeyError de faute de frappe) deviennent des « Erreur : ... » polis et indébogables. On n'attrape que ce que le contrat annonce (ValueError), le reste doit crasher bruyamment pendant le développement — c'est le même argument que pytest.raises(Exception), côté programme.

Variante plus difficile — Le négatif : -1 est-il VRAIMENT invalide ? Tu pourrais décider que les index négatifs de Python sont une fonctionnalité (« -1 = la dernière tâche »). Peu importe le choix — ce qui compte c'est qu'il soit écrit dans un test nommé (test_complete_task_negative_index_targets_last OU ..._raises), pas subi.