Leçon 08-3 — Cas limites et tests d'exceptions

1. Objectif

À la fin de cette leçon tu sauras : choisir les cas de test d'une fonction avec méthode (cas nominal, valeurs limites, entrées vides, valeurs extrêmes, entrées invalides), tester qu'une fonction lève bien l'exception attendue avec pytest.raises, organiser plusieurs tests par fonction sans t'y perdre, et appliquer le réflexe professionnel « bug trouvé = test ajouté ».

2. Pourquoi c'est important

La leçon 08-2 t'a donné l'outil ; celle-ci te donne le jugement. Car un test ne vaut que par le cas qu'il vérifie — et les bugs ne vivent presque jamais dans le cas facile. total_price([10, 20]) marche du premier coup chez tout le monde ; c'est total_price([]), la remise de 100 %, la note exactement égale à 10, le fichier de zéro ligne qui plantent — en production, un soir de démonstration. Savoir énumérer les cas limites d'une fonction AVANT de la considérer finie est une compétence qui te servira même sans tests : c'est littéralement « penser comme un développeur expérimenté ». Et comme le niveau 05 t'a appris à raise des exceptions sur les entrées invalides, il te faut l'outil pour vérifier ces chemins-là aussi : un contrat d'erreur non testé est un contrat non tenu.

3. Explication simple

Choisir ses cas, c'est interroger la fonction à ses frontières. Pour chaque fonction, pose ces questions dans l'ordre :

  1. Nominal : l'usage courant, celui pour lequel la fonction existe. (apply_discount(100, 20)80)
  2. Limites : les bords exacts du domaine. Zéro, le maximum autorisé, la valeur pile sur un seuil. (percent = 0, percent = 100)
  3. Vide : liste vide, chaîne vide, dict vide. LE grand classique.
  4. Extrêmes : très grand, très petit, négatif.
  5. Invalide : ce que la fonction doit refuser — et là, refuser bien, c'est lever une exception (niveau 05).

Pour la catégorie 5, assert ne suffit plus : comment affirmer « cet appel doit planter » ? C'est le travail de pytest.raises :

import pytest

def test_apply_discount_negative_percent_raises():
    with pytest.raises(ValueError):        # « j'affirme que ce bloc leve ValueError »
        apply_discount(100, -5)

Le bloc with (tu l'as vu avec les fichiers, niveau 05) surveille son contenu : si ValueError est levée dedans, le test passe ; si rien n'est levé — ou autre chose — le test échoue. C'est l'assert du monde des exceptions.

4. Explication approfondie

a) Les limites, au cordeau. Le bug de frontière classique est l'écart entre > et >=. Si la règle est « admis à partir de 10 », alors 10 est LA valeur à tester — pas 12, pas 15 : exactement 10, et idéalement 9.99 aussi. Méthode systématique : pour chaque comparaison dans le code, teste la valeur pile sur le seuil et la valeur juste à côté. La plupart des bugs d'un code de gestion vivent sur ces frontières-là.

b) pytest.raises est exigeant dans les deux sens. Le test échoue si l'exception ne vient pas (Failed: DID NOT RAISE ValueError — section 7), mais aussi si une exception d'un AUTRE type arrive (un TypeError inattendu fait échouer un pytest.raises(ValueError)). C'est voulu : ton test décrit un contrat précis. D'où la règle : demande toujours le type le plus précis (ValueError, KeyError…), jamais pytest.raises(Exception) — qui avalerait n'importe quel bug en le prenant pour le comportement attendu.

c) Vérifier aussi le message : match=. Un ValueError peut avoir cent causes. Pour affirmer que c'est le BON refus, on vérifie un morceau du message :

with pytest.raises(ValueError, match="between 0 and 100"):
    apply_discount(100, 150)

match cherche le motif dans le message de l'exception (c'est une expression régulière, ⏩ niveau 09 — avec du texte simple sans caractères spéciaux, ça se comporte comme une recherche de sous-chaîne). Prends un fragment stable et significatif du message, pas la phrase entière au caractère près.

d) Et quand la fonction ne DOIT PAS refuser mais que son comportement sur le cas limite n'est écrit nulle part ? C'est la vraie découverte de cette leçon : écrire les tests des cas limites t'oblige à décider du contrat. Que doit faire average([]) ? Renvoyer 0 ? None ? Lever ValueError ? Les trois se défendent ; ce qui ne se défend pas, c'est l'actuel ZeroDivisionError: division by zero accidentel, que personne n'a choisi et qu'aucun appelant ne s'attend à attraper. Le test transforme une question vague en décision documentée : tu choisis (par exemple : ValueError, car il n'existe pas de moyenne de rien), tu codes le raise, tu écris le test qui verrouille ce choix. Un test de cas limite est une décision de conception gravée dans le code.

e) Le réflexe « bug trouvé = test ajouté ». Quand tu trouves un bug — en utilisant le programme, par un utilisateur, n'importe comment — la procédure professionnelle est toujours la même : (1) écrire d'abord un test qui reproduit le bug (il doit être ROUGE — s'il est vert, tu n'as pas reproduit le bug) ; (2) corriger ; (3) voir le test passer au vert. Le test reste ensuite pour toujours : ce bug précis ne pourra JAMAIS revenir sans déclencher l'alarme. Une suite de tests mûre est un cimetière de bugs — chaque test d'angle bizarre raconte une bataille gagnée.

Difficulté honnête : la mécanique de pytest.raises s'apprend en dix minutes. Ce qui prend des années — et qu'on commence ici — c'est le réflexe d'énumérer les cas AVANT de coder, et l'humilité d'accepter que ta fonction « finie » a en général deux cas limites non traités. Fais l'exercice papier (8-a) sérieusement : lister les cas sans coder est LA compétence de cette leçon.

5. Exemples commentés

Exemple 1 — une fonction avec contrat, et sa suite complète

Le module, avec la validation apprise au niveau 05 :

# cart.py
def apply_discount(price, percent):
    if not 0 <= percent <= 100:
        raise ValueError(f"percent must be between 0 and 100, got {percent}")
    return price * (1 - percent / 100)

La suite, construite avec la grille de la section 3 :

# test_discount.py
import pytest

from cart import apply_discount


def test_apply_discount_nominal():                    # 1. nominal
    assert apply_discount(100, 20) == 80


def test_apply_discount_zero_percent():               # 2. limite basse
    assert apply_discount(100, 0) == 100


def test_apply_discount_hundred_percent():            # 2. limite haute
    assert apply_discount(100, 100) == 0


def test_apply_discount_negative_percent_raises():    # 5. invalide (dessous)
    with pytest.raises(ValueError):
        apply_discount(100, -5)


def test_apply_discount_over_hundred_raises():        # 5. invalide (dessus)
    with pytest.raises(ValueError):
        apply_discount(100, 150)
collected 5 items

test_discount.py::test_apply_discount_nominal PASSED                     [ 20%]
test_discount.py::test_apply_discount_zero_percent PASSED                [ 40%]
test_discount.py::test_apply_discount_hundred_percent PASSED             [ 60%]
test_discount.py::test_apply_discount_negative_percent_raises PASSED     [ 80%]
test_discount.py::test_apply_discount_over_hundred_raises PASSED         [100%]

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

Remarque le schéma de nommage test_<fonction>_<cas> : lancée en -v, la suite se lit comme la spécification de apply_discount. Cinq tests pour une fonction de trois lignes n'est PAS excessif : c'est la couverture normale d'une fonction qui a un domaine borné.

Exemple 2 — pytest.raises attrape un contrat non tenu

Quelqu'un « simplifie » apply_discount et supprime la validation :

# buggy.py — la validation a disparu
def apply_discount(price, percent):
    return price * (1 - percent / 100)
test_didnotraise.py F                                                    [100%]

================================== FAILURES ===================================
________________________ test_negative_percent_raises _________________________

    def test_negative_percent_raises():
>       with pytest.raises(ValueError):
             ^^^^^^^^^^^^^^^^^^^^^^^^^
E       Failed: DID NOT RAISE ValueError

test_didnotraise.py:7: Failed

DID NOT RAISE : le bloc s'est déroulé sans exception. Sans ce test, la suppression de la validation serait passée inaperçue — apply_discount(100, -5) renvoie tranquillement 105.0 (une remise négative qui augmente le prix !). Les tests d'exceptions protègent tes gardes-fous.

Exemple 3 — vérifier le message avec match=

def test_error_message_mentions_bounds():
    with pytest.raises(ValueError, match="between 0 and 100"):
        apply_discount(100, 150)

Ce test passe avec notre cart.py. Si le message ne correspond pas, pytest montre les deux côtés :

E       AssertionError: Regex pattern did not match.
E         Expected regex: 'percentage out of range'
E         Actual message: 'percent must be between 0 and 100, got 150'

Lecture immédiate : l'exception EST venue (bon type), mais son message ne contient pas le motif attendu. Utile aussi pour distinguer deux ValueError différents levés par la même fonction.

Exemple 4 — le cas limite qui force une décision

# average.py — version naive
def average(values):
    return sum(values) / len(values)

Le test du cas vide révèle le trou dans le contrat (sortie réelle, décortiquée en section 7) : ZeroDivisionError — un accident, pas une décision. On décide : liste vide = demande absurde = ValueError. Version contractuelle et sa suite :

# average.py — le contrat est explicite
def average(values):
    if not values:
        raise ValueError("cannot average an empty list")
    return sum(values) / len(values)
# 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([])

Le choix aurait pu être différent (renvoyer None…) — l'important est qu'il soit fait, codé et verrouillé. Quiconque lit test_average_empty_list_raises connaît le contrat sans ouvrir la doc.

Exemple 5 — bug trouvé = test ajouté, en direct

En utilisant ton gestionnaire de tâches, tu découvres qu'ajouter une tâche au titre vide crée une ligne fantôme [ ]. Procédure :

# 1. ROUGE — le test qui reproduit le bug, ecrit AVANT la correction
def test_add_task_empty_title_raises():
    with pytest.raises(ValueError):
        add_task([], "")
# 2. La correction dans tasks.py
def add_task(tasks, title):
    if not title.strip():
        raise ValueError("task title cannot be empty")
    tasks.append({"title": title, "done": False})
    return tasks

3\. VERT — on relance, le test passe, et il reste dans la suite : ce bug est mort définitivement. Remarque le .strip() : le test t'amène à te demander « et un titre fait uniquement d'espaces ? » — ajoute test_add_task_blank_title_raises avec " ". Les cas limites s'appellent entre eux : c'est bon signe, tu penses en frontières.

6. Erreurs fréquentes

1) Le code piégé APRÈS la ligne qui lève

def test_apply_discount_invalid():
    with pytest.raises(ValueError):
        result = apply_discount(100, 150)
        assert result is None          # JAMAIS execute !

Dès que apply_discount lève, le reste du bloc with est abandonné (c'est le principe même d'une exception, niveau 05). Ce assert est du code mort — et s'il y avait un vrai assert important, il serait silencieusement ignoré. Règle : une seule ligne dans le bloc with pytest.raises : l'appel qui doit lever. Les vérifications vont dans d'autres tests.

2) pytest.raises(Exception) : le filet trop large

with pytest.raises(Exception):
    apply_discount("cent", 20)     # voulait tester le refus des chaines...

Exception attrape tout — y compris un TypeError accidentel dû à un bug, une faute de frappe interne, n'importe quoi. Le test est vert pour de mauvaises raisons. Toujours le type précis attendu par le contrat.

3) La fonction qui print l'erreur au lieu de la raise

def apply_discount(price, percent):
    if not 0 <= percent <= 100:
        print("Erreur : pourcentage invalide")     # heritage des vieux reflexes
        return None
    ...

Le test pytest.raises(ValueError) échoue en DID NOT RAISE — et il a raison : un message imprimé n'est pas un contrat, l'appelant reçoit None et plantera plus loin. C'est le pendant « erreurs » de la leçon 08-1 : le cœur du programme raise, la couche externe attrape et affiche (try/except dans main.py).

4) Tester 12 et 15 mais jamais 10 (rater la frontière)

def is_passing(grade):
    return grade > 10        # bug : la regle dit « a partir de 10 »

test_is_passing(12) et test_is_failing(5) : tout vert, bug intact. Seul assert is_passing(10) is True — la valeur PILE sur le seuil — révèle le > qui devait être >=. Chaque comparaison du code mérite son test de frontière.

5) Oublier import pytest

E       NameError: name 'pytest' is not defined

assert est natif, mais pytest.raises doit être importé (import pytest en tête du fichier de tests). L'erreur ne se voit qu'à l'exécution du test concerné.

6) Un seul test-fleuve pour toute la fonction

def test_apply_discount():
    assert apply_discount(100, 20) == 80
    assert apply_discount(100, 0) == 100
    assert apply_discount(100, 100) == 0
    with pytest.raises(ValueError):
        apply_discount(100, -5)

Ça marche, MAIS : au premier assert rouge, les suivants ne tournent pas — tu perds la vision d'ensemble (trois cas cassés apparaissent comme un seul), et le nom test_apply_discount ne te dit plus RIEN sur ce qui est cassé. Un cas = un test nommé. La suite en -v doit se lire comme une spécification.

7. Lire les messages d'erreur

Deux échecs nouveaux dans cette leçon, à savoir distinguer au premier regard.

a) Failed: DID NOT RAISE — l'exception attendue n'est pas venue.

    def test_negative_percent_raises():
>       with pytest.raises(ValueError):
             ^^^^^^^^^^^^^^^^^^^^^^^^^
E       Failed: DID NOT RAISE ValueError

test_didnotraise.py:7: Failed

Le chevron pointe le with lui-même : le bloc s'est terminé sans exception. Deux causes possibles, à départager en ouvrant le code testé : la validation manque (bug — ajoute-la), ou bien elle existe mais ta valeur de test ne la déclenche pas (test raté — es-tu sûr que -5 est invalide pour ce contrat ?).

b) L'exception inattendue — le test a crashé, pas échoué. Voici la sortie réelle du test du cas vide sur la version naïve de average :

___________________________ test_average_empty_list ___________________________

    def test_average_empty_list():
>       assert average([]) == 0
               ^^^^^^^^^^^

test_average.py:5:
_ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _

values = []

    def average(values):
>       return sum(values) / len(values)
               ^^^^^^^^^^^^^^^^^^^^^^^^^
E       ZeroDivisionError: division by zero

average.py:2: ZeroDivisionError

Lecture : DEUX étages séparés par la ligne _ _ _. En haut, la ligne du TEST qui a déclenché ; en bas, la ligne du MODULE où ça a explosé (average.py:2), avec même l'état des variables locales (values = [] — cadeau précieux). Ce n'est pas « l'assertion est fausse », c'est « on n'a jamais atteint la comparaison : le code testé a levé une exception que le test n'attendait pas ». C'est exactement le signal « cas limite non traité » — et la réponse est la démarche de l'exemple 4 : décider du contrat, coder, verrouiller.

8. Exercices — faciles

Règles : sous-dossiers ex-08-3-x/ dans lessons/level-08-tests-bonnes-pratiques/exercices/mes-reponses/ ; python -m pytest depuis le sous-dossier. L'exercice a) se fait sur papier.

a) SANS CODER : pour chacune de ces trois fonctions, liste ses cas de test avec la grille de la section 3 (nominal / limites / vide / extrêmes / invalide) — vise 4 à 6 cas par fonction, avec la valeur exacte et le résultat attendu : count_words(text) (nombre de mots d'une chaîne), last_item(items) (dernier élément d'une liste), grade_to_mention(grade) (note sur 20 → mention, admis à partir de 10).

b) Recrée le couple cart.py / test_discount.py de l'exemple 1 (tapé, pas copié), suite verte. Puis reproduis l'exemple 2 : supprime la validation, constate les deux DID NOT RAISE, remets-la.

c) Ce test est cassé (il passe même quand il devrait échouer). Trouve le défaut sans exécuter, corrige, puis démontre par un sabotage de add_task que ta version corrigée détecte ce que l'originale ratait :

def test_add_task_empty_title():
    with pytest.raises(ValueError):
        add_task([], "")
        tasks = add_task([], "ok")
        assert len(tasks) == 1

9. Exercices — moyens

a) Implémente average version contractuelle (exemple 4) et sa suite de quatre tests, PUIS étends le contrat : la fonction doit aussi refuser (TypeError) une valeur non numérique dans la liste, avec un message contenant la valeur fautive. Écris les tests d'abord (rouge), le code ensuite (vert). Piège : pytest.raises(TypeError) et pytest.raises(ValueError) sont deux tests distincts — pourquoi pas un seul avec Exception ? (Relis l'erreur n°2.)

b) Écris grades.py avec grade_to_mention(grade) : >= 16"tres bien", >= 14"bien", >= 12"assez bien", >= 10"passable", sinon "insuffisant" ; hors [0, 20]ValueError avec un message mentionnant les bornes. Écris la suite complète AVANT le code : les quatre frontières exactes (10, 12, 14, 16), les deux bornes du domaine (0 et 20), un nominal par mention, les deux invalides (-1 et 21, avec match=). Environ douze tests — c'est normal, compte tes comparaisons.

c) Bug trouvé = test ajouté : ton slugify de la leçon 08-2 transforme "Python & Git" en "python-&-git" — or un slug d'URL ne doit contenir que lettres, chiffres et tirets. Procédure stricte : (1) test rouge qui reproduit, (2) correction, (3) vert, (4) note dans ton journal la date et le bug — tu commences ton cimetière de bugs.

10. Exercices — difficiles

a) Chasse aux bugs à l'aveugle. Voici stats.py :

def median(values):
    ordered = sorted(values)
    middle = len(values) // 2
    return ordered[middle]

Cette fonction contient DEUX défauts de cas limites. Écris une suite de tests qui suit la grille de la section 3 (sans regarder les indices !), laisse les tests te montrer les défauts, puis corrige median et son contrat jusqu'à la suite verte.

11. Mini-projet lié — durcir le gestionnaire de tâches

Ta suite de la leçon 08-2 couvre les cas nominaux. Maintenant, le blindage, en 30–45 min :

  1. Audit papier (10 min) : pour chaque fonction pure de tasks.py, applique la grille des cinq questions et note les cas non couverts par ta suite actuelle. Attendus classiques : liste de tâches vide, index/titre inexistant, titre vide ou blanc, tâche déjà terminée qu'on re-termine.
  2. Contrats : pour chaque entrée invalide, décide — exception (raise ValueError avec message clair) ou comportement défini (renvoyer None, ignorer ?). Décision explicite, pas d'accident hérité.
  3. Tests d'abord : écris les tests des nouveaux contrats (rouges pour les validations manquantes), puis code jusqu'au vert. Chaque raise de tasks.py doit avoir son pytest.raises, match= inclus.
  4. Interface : vérifie que main.py attrape ces nouvelles exceptions (try/except, niveau 05) et affiche un message propre — provoque chaque erreur à la main dans le programme.

Critère de réussite : ta suite a environ doublé ; chaque fonction pure a au moins un test de limite et (si elle valide) un test d'exception ; et le programme ne peut plus être crashé depuis le menu, quelle que soit la saisie.

12. Correction / méthode de correction

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

Méthode : (1) finis TOUS les exercices avant d'ouvrir les solutions ; (2) pour l'exercice papier 8-a, compare ta LISTE de cas à celle de la solution — chaque cas que tu as raté est plus instructif qu'un bug de code, note-le ; (3) compare les contrats choisis : un choix différent du corrigé peut être valable s'il est cohérent et testé ; (4) refais de tête l'exercice raté le lendemain.

13. À retenir

14. Questions de révision

  1. Récite la grille des cinq familles de cas et donne un exemple de chaque pour count_words(text).
  2. Pourquoi teste-t-on la valeur exactement égale au seuil, et pas seulement au-dessus et en dessous ?
  3. Que se passe-t-il si le bloc with pytest.raises(ValueError) ne lève rien ? S'il lève un TypeError ?
  4. Pourquoi pytest.raises(Exception) est-il un piège ?
  5. À quoi ressemble le rapport pytest quand le code testé lève une exception que le test n'attendait pas ? Comment le distinguer d'un échec d'assert ?
  6. Que doit faire average([]) ? Justifie ton choix et explique pourquoi le ZeroDivisionError accidentel n'est pas acceptable.
  7. Décris la procédure « bug trouvé = test ajouté ». Pourquoi le test doit-il être rouge avant la correction ?
  8. Pourquoi met-on une seule ligne dans le bloc with pytest.raises ?

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-3 edge cases and exception tests"

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