1ʳᵉ NSI
Docstring, préconditions et postconditions, assert, jeux de tests
Écrire une fonction qui « marche » sur un exemple ne suffit pas. Il faut d'abord dire précisément ce qu'elle doit faire, c'est sa spécification, puis vérifier qu'elle le fait dans tous les cas prévus, ce sont ses tests. La spécification est un contrat entre celui qui écrit la fonction et celui qui l'utilise : elle fixe ce que l'appelant doit garantir (les préconditions) et ce que la fonction promet en retour (les postconditions). Les tests ne prouvent jamais qu'un programme est correct, mais ils révèlent les erreurs ; un bon jeu de tests vise les cas limites, là où les bugs se cachent.

Prérequis : Les fonctions, Structures conditionnelles.

Mémo

Spécifier une fonction
Prototype
Le nom de la fonction, ses paramètres (avec le type attendu) et le type de la valeur renvoyée.
Docstring
Chaîne placée juste sous la ligne def, entre triples guillemets : rôle de la fonction, paramètres, valeur renvoyée, exemple. Elle s'affiche avec help(f).
Préconditions
Ce que l'appelant doit garantir avant l'appel (type, domaine : liste non vide, entier positif…). Si elles ne sont pas respectées, la fonction n'a rien à promettre.
Postconditions
Ce que la fonction garantit après l'appel, lorsque les préconditions étaient respectées (une propriété du résultat).
L'instruction assert
assert condition, "message affiché si la condition est fausse"
  • Si la condition est vraie, rien ne se passe. Si elle est fausse, Python arrête le programme avec une erreur AssertionError et affiche le message.
  • Premier usage : vérifier une précondition au début d'une fonction.
  • Second usage : écrire un test, en comparant le résultat obtenu au résultat attendu.
  • Le message est facultatif mais précieux : il dit ce qui a échoué.
Construire un jeu de tests
Cas normaux
Les situations courantes, avec un résultat calculé à la main.
Cas limites
Les bords du domaine : liste vide ou à un seul élément, zéro, nombres négatifs, chaîne vide.
Cas particuliers
Doublons, éléments tous égaux, liste déjà triée, majuscules et minuscules.
Règle
Un test qui échoue prouve qu'il y a un bug. Un test qui réussit ne prouve pas que la fonction est correcte : il augmente seulement la confiance.
Organisation
Regrouper les assert dans une fonction test_nom(), appelée après chaque modification du code.
Pièges fréquents
  • Écrire assert(cond, "msg") avec des parenthèses : le couple est un tuple non vide, donc toujours vrai, et l'assertion ne détecte plus rien.
  • Ne tester que les cas faciles et oublier la liste vide, le zéro ou les négatifs.
  • Comparer des flottants avec == : 0.1 + 0.2 == 0.3 vaut False.
  • Confondre print et return : une fonction qui affiche le bon résultat mais renvoie None échoue à tous les tests.
  • Placer la docstring après le code de la fonction au lieu de la première ligne du corps.
Erreurs classiques
Code erronéCode correctExplication
assert(x > 0, "x positif attendu")assert x > 0, "x positif attendu"Avec les parenthèses, Python évalue le tuple (x > 0, "..."), qui est toujours vrai : l'assertion ne se déclenche jamais. Python signale d'ailleurs un SyntaxWarning.
def moyenne(L):
  return sum(L) / len(L) sans précondition
assert len(L) > 0, "liste vide" en première ligne du corpsSur une liste vide, la division par zéro provoque un ZeroDivisionError peu explicite ; la précondition donne un message clair et documente le contrat.
assert aire(1) == 3.141592653589793assert abs(aire(1) - 3.14159) < 1e-4Les flottants sont des valeurs approchées : on compare à une tolérance, jamais avec l'égalité stricte.
def double(x):
  return 2 * x
  """Double x."""
Docstring en première ligne du corps, avant le codePlacée après le return, la chaîne n'est jamais atteinte et help(double) ne l'affiche pas.

Exemples

Une fonction spécifiée et testée
def moyenne(L):
    """Renvoie la moyenne des nombres de la liste L.

    Précondition : L est une liste non vide de nombres.
    Postcondition : le résultat est compris entre min(L) et max(L).
    Exemple : moyenne([10, 14, 12]) renvoie 12.0
    """
    assert len(L) > 0, "moyenne : la liste ne doit pas être vide"
    total = 0
    for x in L:
        total += x
    return total / len(L)

print(moyenne([10, 14, 12]))   # 12.0
Une précondition non respectée
moyenne([])

Python s'arrête et affiche la dernière ligne du message d'erreur :

AssertionError: moyenne : la liste ne doit pas être vide
Un jeu de tests regroupé dans une fonction
def est_bissextile(annee):
    """Renvoie True si annee est bissextile (calendrier grégorien)."""
    return (annee % 4 == 0 and annee % 100 != 0) or annee % 400 == 0

def test_est_bissextile():
    assert est_bissextile(2024) == True     # divisible par 4
    assert est_bissextile(2023) == False    # cas normal
    assert est_bissextile(1900) == False    # divisible par 100, pas par 400
    assert est_bissextile(2000) == True     # divisible par 400
    print("test_est_bissextile : tous les tests passent")

test_est_bissextile()

Un jeu de tests pour maximum(L) :

FamilleEntréeRésultat attendu
cas normal[3, 7, 2]7
un seul élément[5]5
tous négatifs[-3, -7, -2]-2
doublons[4, 4, 4]4
maximum en première position[9, 1, 2]9
maximum en dernière position[1, 2, 9]9
Un test qui révèle un bug
def maximum(L):
    maxi = 0                  # erreur : initialisation à 0
    for x in L:
        if x > maxi:
            maxi = x
    return maxi

assert maximum([3, 7, 2]) == 7          # passe : le bug reste invisible
assert maximum([-3, -7, -2]) == -2      # échoue : AssertionError

Le second test échoue, car maximum([-3, -7, -2]) renvoie 0. Le cas limite « tous négatifs » révèle l'erreur d'initialisation ; la correction est maxi = L[0].

Postcondition vérifiée dans la fonction
def racine_entiere(n):
    """Renvoie le plus grand entier k tel que k * k <= n.

    Précondition : n est un entier positif ou nul.
    """
    assert isinstance(n, int) and n >= 0, "n doit être un entier positif ou nul"
    k = 0
    while (k + 1) * (k + 1) <= n:
        k += 1
    assert k * k <= n < (k + 1) * (k + 1)     # postcondition
    return k

print(racine_entiere(15), racine_entiere(16), racine_entiere(2026))   # 3 4 45

Exercices

Exercice 1 — Spécifier puis coder

On veut une fonction division_euclidienne(a, b) qui renvoie le couple (q, r) du quotient et du reste de la division de a par b, sans utiliser les opérateurs // et %.

  1. Écrire la docstring : rôle, préconditions sur a et b, postcondition reliant a, b, q et r.
  2. Coder la fonction par soustractions successives, avec une assertion pour la précondition et une pour la postcondition.
  3. Écrire test_division_euclidienne() avec quatre tests, dont les cas a = 0, a < b et a multiple de b.
▶ Solution — Exercice 1
def division_euclidienne(a, b):
    """Renvoie (q, r), quotient et reste de la division euclidienne de a par b.

    Préconditions : a et b sont des entiers, a >= 0 et b > 0.
    Postcondition : a == b * q + r et 0 <= r < b.
    """
    assert isinstance(a, int) and isinstance(b, int), "a et b entiers attendus"
    assert a >= 0 and b > 0, "il faut a >= 0 et b > 0"
    q = 0
    r = a
    while r >= b:
        r = r - b
        q = q + 1
    assert a == b * q + r and 0 <= r < b
    return (q, r)

def test_division_euclidienne():
    assert division_euclidienne(7, 3) == (2, 1)     # cas normal
    assert division_euclidienne(0, 5) == (0, 0)     # a = 0
    assert division_euclidienne(3, 5) == (0, 3)     # a < b
    assert division_euclidienne(12, 4) == (3, 0)    # a multiple de b
    print("test_division_euclidienne : tous les tests passent")

test_division_euclidienne()
Exercice 2 — Un jeu de tests qui révèle un bug

La fonction suivante doit compter les éléments strictement positifs d'une liste.

def nb_positifs(L):
    """Renvoie le nombre d'éléments strictement positifs de L."""
    n = 0
    for x in L:
        if x >= 0:
            n += 1
    return n
  1. Proposer trois tests sous forme d'assertions, dont au moins un qui échoue.
  2. Quel cas révèle le bug ? Pourquoi les autres tests ne le voient-ils pas ?
  3. Corriger la fonction.
▶ Solution — Exercice 2
assert nb_positifs([1, 2, 3]) == 3      # passe
assert nb_positifs([-1, -2]) == 0       # passe
assert nb_positifs([0, 1, -3]) == 1     # échoue : renvoie 2

Cas révélateur : la présence de 0. Avec >=, le zéro est compté alors qu'il n'est pas strictement positif. Les deux premiers tests ne contiennent pas de zéro, donc > et >= donnent le même résultat : le bug est invisible.

Correction : remplacer if x >= 0: par if x > 0:. Le troisième test passe alors : nb_positifs([0, 1, -3]) renvoie 1.

Exercice 3 — Tester une fonction sur les chaînes
def nb_voyelles(mot):
    """Renvoie le nombre de voyelles (a, e, i, o, u, y) de mot, majuscules comprises."""
    compteur = 0
    for lettre in mot:
        if lettre.lower() in "aeiouy":
            compteur += 1
    return compteur
  1. Écrire test_nb_voyelles() avec au moins quatre tests : un mot sans voyelle, la chaîne vide, un mot en majuscules, un mot formé uniquement de voyelles.
  2. Un camarade a oublié .lower(). Lequel de tes tests échoue avec sa version ? Quelle valeur renvoie-t-elle ?
▶ Solution — Exercice 3
def test_nb_voyelles():
    assert nb_voyelles("rythm") == 1        # une seule voyelle, le y
    assert nb_voyelles("xkcd") == 0         # sans voyelle
    assert nb_voyelles("") == 0             # chaîne vide
    assert nb_voyelles("PYTHON") == 2       # majuscules : Y et O
    assert nb_voyelles("aeiouy") == 6       # que des voyelles
    print("test_nb_voyelles : tous les tests passent")

test_nb_voyelles()

Sans .lower(), seul le test nb_voyelles("PYTHON") == 2 échoue : la version fautive compare "P", "Y", … à des lettres minuscules et renvoie 0. C'est le cas « majuscules » qui révèle l'oubli ; tous les autres tests passent.

Exercice 4 — Le piège de l'assertion
  1. Expliquer pourquoi assert(x >= 0, "x doit être positif") ne signale jamais d'erreur, même pour x = -5. Corriger.
  2. Écrire chiffres(n) qui renvoie la liste des chiffres de l'entier n (précondition : n entier positif ou nul), par exemple chiffres(2026) renvoie [2, 0, 2, 6]. Vérifier la précondition par une assertion, puis, avant le return, vérifier par une assertion la postcondition : les chiffres renvoyés recomposent bien n.
  3. Tester avec 0, 7 et 2026.
▶ Solution — Exercice 4
  1. Les parenthèses créent le tuple (x >= 0, "x doit être positif"). Un tuple non vide est toujours considéré comme vrai, donc l'assertion ne se déclenche jamais. Correction : assert x >= 0, "x doit être positif", sans parenthèses.
  2.  

    def chiffres(n):
        """Renvoie la liste des chiffres de n, de gauche à droite.
    
        Précondition : n est un entier positif ou nul.
        Postcondition : les chiffres renvoyés recomposent n.
        """
        assert isinstance(n, int) and n >= 0, "n doit être un entier positif ou nul"
        if n == 0:
            return [0]
        L = []
        reste = n
        while reste > 0:
            L.append(reste % 10)
            reste = reste // 10
        L.reverse()
        valeur = 0
        for c in L:
            valeur = valeur * 10 + c
        assert valeur == n, "les chiffres ne recomposent pas n"
        return L
  3. chiffres(0) renvoie [0], chiffres(7) renvoie [7], chiffres(2026) renvoie [2, 0, 2, 6]. Le cas n = 0 est traité à part : sans lui, la boucle ne s'exécute pas et la fonction renverrait la liste vide.