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) :
| Famille | Entrée | Attendu |
|---|---|---|
| nominal | "bonjour le monde" | 3 |
| limite | "mot" (un seul mot) | 1 |
| vide | "" | 0 |
| limite sournoise | " " (que des espaces) | 0 (pas 1 !) |
| extrême | 10 000 mots | 10000 (et vite) |
| invalide | count_words(42) | à décider : TypeError |
last_item(items) :
| Famille | Entrée | Attendu |
|---|---|---|
| nominal | [1, 2, 3] | 3 |
| limite | [7] | 7 |
| vide | [] | à décider : ValueError recommandé (l'IndexError de items[-1] serait un accident) |
| extrême | liste de 100 000 éléments | le dernier |
| nominal 2 | ["a", "b"] (types variés) | "b" |
grade_to_mention(grade) (admis à partir de 10) :
| Famille | Entrée | Attendu |
|---|---|---|
| nominal | 13 | "assez bien" |
| frontières | 10, 12, 14, 16 | "passable", "assez bien", "bien", "tres bien" |
| juste sous un seuil | 9.9 | "insuffisant" |
| limites du domaine | 0, 20 | "insuffisant", "tres bien" |
| invalide | -1, 21 | ValueError |
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.