Programmation structurée (Python) · L1 · Section 9/11
Tests unitaires
Progression
#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
unittestet 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
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 passentSi 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
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:
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:
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
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 rienne 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
#Exercices
- 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.
- 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écutezdoctest.testmod(). - Test d'exception. Vérifiez avec
assertRaisesqueretire(100, 150)(chapitre Exceptions) lèveSoldeInsuffisant. - Chasse à la régression. On vous donne
pgcdsans la garde sur les négatifs; écrivez le test qui échoue, puis corrigez la fonction pour le faire passer.
#Corrections
Correction: suite unittest
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
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 doctestLe cas singleton documente le comportement à l'étendue nulle: le eps évite la division par zéro, tout tombe à 0.0.
Correction: test d'exception
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
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.