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 marche — pytest.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 difficile — median([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.