Aller au contenu principal

Programmation structurée (Python) · L1 · Section 9/11

Tests unitaires

Progression

Points d’expérience : XPSérie de jours consécutifs : · —Progression du module : — / —compris

#Tests unitaires

Un test unitaire vérifie une unité de comportement (une fonction, une méthode) par une assertion simple. Il protège contre les régressions: un changement qui casse un comportement existant fait échouer le test immédiatement, avec un message qui désigne le coupable.

Prérequis: fonctions, exceptions.

Objectifs d'apprentissage:

  • Écrire des tests par assertions sur des cas connus et des cas limites.
  • Organiser des tests avec unittest et les lancer.
  • Utiliser les doctests pour tester et documenter en même temps.
  • Nommer et structurer les tests pour qu'ils restent lisibles et déterministes.

#Assertions: le réflexe minimal

pythonpython

1def convertir_en_secondes(h: int, m: int, s: int) -> int:2    return h * 3600 + m * 60 + s3 4assert convertir_en_secondes(1, 1, 11) == 36715assert convertir_en_secondes(0, 0, 0) == 06print('tous les tests passent')7# Sortie: tous les tests passent

Si une assertion échoue, Python lève AssertionError et s'arrête à la première faute. Suffisant pour vérifier ponctuellement une fonction en cours d'écriture; pour organiser une suite de tests, il faut un cadre.

#unittest: organiser une suite

pythonpython

1import unittest2 3def pgcd(a: int, b: int) -> int:4    if a < 0 or b < 0:5        raise ValueError("pgcd exige des entiers positifs ou nuls")6    while b:7        a, b = b, a % b8    return a9 10class TestPgcd(unittest.TestCase):11    def test_cas_generaux(self):12        self.assertEqual(pgcd(48, 18), 6)13        self.assertEqual(pgcd(18, 48), 6)      # symétrie14 

Chaque méthode test_* est un cas indépendant. Lancement: python test_pgcd.py affiche un point par test réussi et le détail des échecs (valeur attendue contre valeur obtenue).

#doctest: la documentation testée

Les exemples dans la docstring deviennent exécutables: chaque ligne >>> est évaluée et sa sortie comparée:

pythonpython

1def moyenne(valeurs: list[float]) -> float:2    """Retourne la moyenne arithmétique.3 4    >>> moyenne([12, 18])5    15.06    >>> moyenne([10])7    10.08    """9    return sum(valeurs) / len(valeurs)10 11if __name__ == '__main__':12    import doctest13    doctest.testmod(verbose=True)

python moyenne.py -v exécute les deux exemples et les confirme. La docstring reste la documentation de la fonction, mais devient fausse si le code change: le doctest échoue et signale la documentation à mettre à jour.

#pytest: la forme concise

pytest découvre les fonctions test_* et remplace les méthodes de classe par des assert nus:

pythonpython

1def test_pgcd_cas_general():2    assert pgcd(48, 18) == 63 4def test_pgcd_symetrie():5    assert pgcd(18, 48) == pgcd(48, 18)

Lancement: pytest dans le dossier des tests; chaque échec affiche l'expression, la valeur attendue et la valeur reçue. Même philosophie que unittest, moins de cérémonie.

#Quoi tester

  • Cas nominaux: des entrées représentatives (pgcd(48, 18)).
  • Cas limites: entrée vide, zéro, un seul élément, plus grande valeur attendue (pgcd(7, 0)).
  • Erreurs attendues: lève la bonne exception (with self.assertRaises(ValueError)).
  • Propriétés: invariants qui tiennent pour toute entrée (symétrie du pgcd).

Un jeu de tests est bon quand l'échec d'un test suffit à identifier la régression, et quand couvrir un cas limite empêche un bug réel plutôt qu'il rassure.

#Test ou preuve : deux niveaux de vérification

Un test vérifie un comportement sur des entrées choisies; une preuve d'invariant l'établit pour toutes les entrées. Les deux sont complémentaires et les TD d'algorithmique demandent explicitement le second: montrer qu'une boucle de tri par insertion, de fusion ou de tri par comptage maintient sa propriété à chaque tour.

La différence est concrète. Écrire

pythonpython

1def tri_selection(a: list[int]) -> None:2    n = len(a)3    for i in range(n - 1):4        mini = i5        for j in range(i + 1, n):6            if a[j] < a[mini]:7                mini = j8        a[i], a[mini] = a[mini], a[i]9 10a = [5, 2, 4, 1, 3]11resultat = tri_selection(a)12assert a == [1, 2, 3, 4, 5]      # passe : le tri a eu lieu en place13assert resultat is None          # la fonction ne retourne rien

ne dit rien sur les tableaux de 10 000 éléments, sur les doublons ou sur le tableau vide. L'invariant, lui, énonce au début de l'itération i, a[0:i] contient les i plus petits éléments, triés, et la démonstration des trois obligations (initialisation, conservation, terminaison) couvre toutes les entrées d'un coup.

Règle pratique: prouver l'algorithme quand la propriété est énonçable (tri, recherche, comptage), tester quand elle ne l'est pas (formatage, entrées/sorties, comportement en cas d'erreur). Un invariant se traduit d'ailleurs souvent en assertion de test: on peut vérifier, sur quelques entrées tirées au hasard, que l'invariant tient à chaque tour — c'est le pont entre les deux approches.

#Playground

Chargement de l’éditeur...

#Exercices

  1. Suite unittest pour convertir_en_secondes. Écrivez une classe de test couvrant un cas nominal, le cas zéro, et un cas à trois chiffres par composante. Toutes les assertions doivent passer.
  2. Doctest pour normalise. Documentez normalise([2, 4, 6]) -> [0.0, 0.5, 1.0] dans une docstring avec deux exemples (cas général, cas singleton) et exécutez doctest.testmod().
  3. Test d'exception. Vérifiez avec assertRaises que retire(100, 150) (chapitre Exceptions) lève SoldeInsuffisant.
  4. Chasse à la régression. On vous donne pgcd sans la garde sur les négatifs; écrivez le test qui échoue, puis corrigez la fonction pour le faire passer.

#Corrections

Correction: suite unittest
pythonpython

1import unittest2 3def convertir_en_secondes(h: int, m: int, s: int) -> int:4    return h * 3600 + m * 60 + s5 6class TestConversion(unittest.TestCase):7    def test_cas_nominal(self):8        self.assertEqual(convertir_en_secondes(1, 1, 11), 3671)9 10    def test_zero(self):11        self.assertEqual(convertir_en_secondes(0, 0, 0), 0)12 13    def test_composantes_elevees(self):14        self.assertEqual(convertir_en_secondes(12, 34, 56), 45296)

Vérification du dernier cas: 12 x 3600 + 34 x 60 + 56 = 43200 + 2040 + 56 = 45296.

Correction: doctest pour normalise
pythonpython

1def normalise(valeurs: list[float], eps: float = 1e-9) -> list[float]:2    """Ramène les valeurs dans [0, 1]; invariant: min -> 0.0, max -> 1.0.3 4    >>> normalise([2, 4, 6])5    [0.0, 0.5, 1.0]6    >>> normalise([7])7    [0.0]8    """9    m, M = min(valeurs), max(valeurs)10    etendue = max(eps, M - m)11    return [(v - m) / etendue for v in valeurs]12 13if __name__ == '__main__':14    import doctest

Le cas singleton documente le comportement à l'étendue nulle: le eps évite la division par zéro, tout tombe à 0.0.

Correction: test d'exception
pythonpython

1import unittest2 3class SoldeInsuffisant(Exception):4    pass5 6def retire(solde: float, montant: float) -> float:7    if montant > solde:8        raise SoldeInsuffisant(f"solde {solde}, retrait {montant}")9    return solde - montant10 11class TestRetrait(unittest.TestCase):12    def test_refuse_si_insuffisant(self):13        with self.assertRaises(SoldeInsuffisant):14            retire(100, 150)

assertRaises échoue si l'exception ne survient pas: il vérifie l'échec attendu, pas seulement le succès.

Correction: chasse à la régression
pythonpython

1# Étape 1: le test qui échoue (pgcd accepte silencieusement -4)2def test_rejette_les_negatifs():3    with unittest.TestCase().assertRaises(ValueError):4        pgcd(-4, 6)5 6# Étape 2: la correction qui le fait passer7def pgcd(a: int, b: int) -> int:8    if a < 0 or b < 0:9        raise ValueError("pgcd exige des entiers positifs ou nuls")10    while b:11        a, b = b, a % b12    return a

Écrire le test défaillant d'abord prouve qu'il détecte bien le bug: un test qui n'échoue jamais ne protège de rien.

#Bonnes pratiques

  • Un test par comportement, indépendant: aucun ordre requis, aucun état partagé entre tests.
  • Déterministe: pas de date courante, d'aléatoire non semé ni de réseau; sinon les échecs sont irréguliers et ignorés.
  • Rapide: une suite lente n'est pas lancée; les entrées lentes vont dans des tests d'intégration séparés.
  • Quand un bug survient en production: écrivez d'abord le test qui le reproduit, puis corrigez. Le test reste et protège.

#Quiz

Quel trait rend un test unitaire efficace ?
Quel trait rend un test unitaire efficace ?
Que teste un doctest ?
Que teste un doctest ?