def additionner(a, b):
return a + b
# Test manuel
assert additionner(2, 3) == 5
assert additionner(0, 0) == 0
assert additionner(-1, 1) == 0
print("Tous les tests OK !")Tous les tests OK !
Comment garantir que votre code marche ? Et surtout : comment garantir qu’il continue à marcher après chaque modification ? La réponse professionnelle : les tests unitaires. Python offre deux outils : unittest (bibliothèque standard) et pytest (tierce, dominant dans la pratique moderne). Les tests sont attendus du niveau Expert et constituent un skill pro essentiel.
Sans tests :
Avec des tests :
Ce chapitre couvre les tests unitaires.
assert)def additionner(a, b):
return a + b
# Test manuel
assert additionner(2, 3) == 5
assert additionner(0, 0) == 0
assert additionner(-1, 1) == 0
print("Tous les tests OK !")Tous les tests OK !
C’est un début, mais limité :
assert échoue, tout s’arrête.Les frameworks résolvent tout ça.
unittest — le framework standardunittest est dans la bibliothèque standard. Inspiré de JUnit (Java), il utilise une approche orientée objet.
import unittest
def additionner(a, b):
return a + b
class TestAdditionner(unittest.TestCase):
def test_positifs(self):
self.assertEqual(additionner(2, 3), 5)
def test_negatifs(self):
self.assertEqual(additionner(-2, -3), -5)
def test_zero(self):
self.assertEqual(additionner(0, 5), 5)
# Lancement dans un script : unittest.main()
# Dans un notebook, on simule :
suite = unittest.TestLoader().loadTestsFromTestCase(TestAdditionner)
runner = unittest.TextTestRunner(verbosity=2)
runner.run(suite)<unittest.runner.TextTestResult run=3 errors=0 failures=0>
unittest.TestCase.test_ — c’est le framework qui les détecte.self.assert* pour vérifier.| Méthode | Vérifie |
|---|---|
assertEqual(a, b) |
a == b |
assertNotEqual(a, b) |
a != b |
assertTrue(x) |
bool(x) is True |
assertFalse(x) |
bool(x) is False |
assertIs(a, b) |
a is b |
assertIsNone(x) |
x is None |
assertIn(a, b) |
a in b |
assertIsInstance(x, cls) |
isinstance(x, cls) |
assertRaises(exc) |
Lève l’exception attendue |
assertAlmostEqual(a, b) |
Égalité approchée (flottants) |
import unittest
def diviser(a, b):
if b == 0:
raise ZeroDivisionError("Division par zéro")
return a / b
class TestDiviser(unittest.TestCase):
def test_division_normale(self):
self.assertEqual(diviser(10, 2), 5)
def test_division_par_zero(self):
with self.assertRaises(ZeroDivisionError):
diviser(10, 0)
def test_message_erreur(self):
with self.assertRaises(ZeroDivisionError) as ctx:
diviser(10, 0)
self.assertIn("Division par zéro", str(ctx.exception))
suite = unittest.TestLoader().loadTestsFromTestCase(TestDiviser)
unittest.TextTestRunner(verbosity=2).run(suite)<unittest.runner.TextTestResult run=3 errors=0 failures=0>
setUp et tearDownMéthodes appelées avant/après chaque test — pour préparer un état et nettoyer.
import unittest
class CompteBancaire:
def __init__(self, solde=0):
self.solde = solde
def deposer(self, montant):
self.solde += montant
def retirer(self, montant):
if montant > self.solde:
raise ValueError("Fonds insuffisants")
self.solde -= montant
class TestCompteBancaire(unittest.TestCase):
def setUp(self):
"""Appelé AVANT chaque test."""
self.compte = CompteBancaire(100)
def test_depot(self):
self.compte.deposer(50)
self.assertEqual(self.compte.solde, 150)
def test_retrait(self):
self.compte.retirer(30)
self.assertEqual(self.compte.solde, 70)
def test_retrait_excessif(self):
with self.assertRaises(ValueError):
self.compte.retirer(200)
suite = unittest.TestLoader().loadTestsFromTestCase(TestCompteBancaire)
unittest.TextTestRunner(verbosity=2).run(suite)<unittest.runner.TextTestResult run=3 errors=0 failures=0>
setUp est rejoué avant chaque test : on a toujours un compte neuf avec 100 €. Impossible qu’un test en pollue un autre.
pytest — le framework modernepytest est une bibliothèque tierce (pip install pytest) qui est devenue le standard de facto dans le monde Python. Plus concis, plus puissant, plus Pythonique que unittest.
# fichier : test_calcul.py
def additionner(a, b):
return a + b
def test_positifs():
assert additionner(2, 3) == 5
def test_negatifs():
assert additionner(-2, -3) == -5Lancer les tests :
pytest # exécute tous les test_*.py du dossier
pytest -v # verbeux
pytest test_calcul.py # un fichier précis
pytest test_calcul.py::test_positifs # un test précisassert — pas de classe à écrire.assertEqual etc. — le bon vieux assert suffit.setUp.import pytest
def diviser(a, b):
if b == 0:
raise ZeroDivisionError("Division par zéro")
return a / b
def test_division_par_zero():
with pytest.raises(ZeroDivisionError):
diviser(10, 0)
def test_message_exception():
with pytest.raises(ZeroDivisionError, match="Division par zéro"):
diviser(10, 0)Le match= vérifie en plus que le message contient le texte donné (regex).
Une fixture est une fonction qui prépare un contexte pour les tests. On la déclare avec @pytest.fixture.
import pytest
class CompteBancaire:
def __init__(self, solde=0):
self.solde = solde
def deposer(self, montant):
self.solde += montant
@pytest.fixture
def compte():
return CompteBancaire(100)
def test_depot(compte):
compte.deposer(50)
assert compte.solde == 150
def test_solde_initial(compte):
assert compte.solde == 100pytest injecte la fixture dans chaque test qui en déclare le nom en paramètre. Magique et puissant.
Pour tester plusieurs cas avec la même logique :
import pytest
def carre(x):
return x ** 2
@pytest.mark.parametrize("entree, attendu", [
(0, 0),
(1, 1),
(2, 4),
(3, 9),
(-4, 16),
])
def test_carre(entree, attendu):
assert carre(entree) == attendupytest lance le test 5 fois, avec les paires (entrée, attendu). Rapport détaillé cas par cas.
Imaginons une fonction calculer_moyenne.
# fichier : moyenne.py
def calculer_moyenne(notes):
"""Renvoie la moyenne, ou None si la liste est vide."""
if not notes:
return None
return sum(notes) / len(notes)unittest# fichier : test_moyenne_unittest.py
import unittest
from moyenne import calculer_moyenne
class TestCalculerMoyenne(unittest.TestCase):
def test_liste_normale(self):
self.assertEqual(calculer_moyenne([10, 20, 30]), 20)
def test_liste_vide(self):
self.assertIsNone(calculer_moyenne([]))
def test_une_seule_valeur(self):
self.assertEqual(calculer_moyenne([42]), 42)
def test_flottants(self):
self.assertAlmostEqual(calculer_moyenne([1.1, 2.2, 3.3]), 2.2)
if __name__ == "__main__":
unittest.main()pytest# fichier : test_moyenne_pytest.py
import pytest
from moyenne import calculer_moyenne
def test_liste_normale():
assert calculer_moyenne([10, 20, 30]) == 20
def test_liste_vide():
assert calculer_moyenne([]) is None
def test_une_seule_valeur():
assert calculer_moyenne([42]) == 42
@pytest.mark.parametrize("notes, attendu", [
([1, 2, 3], 2),
([0, 0, 0], 0),
([10], 10),
([100, 200], 150),
])
def test_varies(notes, attendu):
assert calculer_moyenne(notes) == attenduMême logique, beaucoup moins de code. C’est pourquoi pytest domine aujourd’hui.
Pour tester un peu de code pytest en environnement Quarto, voici une démo minimale :
# Simule des tests sans framework
def calculer_moyenne(notes):
if not notes:
return None
return sum(notes) / len(notes)
def lancer_tests():
"""Mini-runner de test maison."""
tests = [
("liste normale", lambda: calculer_moyenne([10, 20, 30]) == 20),
("liste vide", lambda: calculer_moyenne([]) is None),
("une valeur", lambda: calculer_moyenne([42]) == 42),
("deux valeurs", lambda: calculer_moyenne([10, 20]) == 15),
]
ok = 0
for nom, test in tests:
try:
if test():
print(f" ✅ {nom}")
ok += 1
else:
print(f" ❌ {nom}")
except Exception as e:
print(f" 💥 {nom} : {e}")
print(f"\n{ok}/{len(tests)} tests réussis.")
lancer_tests() ✅ liste normale
✅ liste vide
✅ une valeur
✅ deux valeurs
4/4 tests réussis.
En vrai projet, pytest ferait tout ça mille fois mieux. Mais ça vous donne l’idée.
La couverture mesure quelle proportion du code est exécutée par les tests.
pip install coverage
coverage run -m pytest
coverage report
coverage html # rapport HTML joliExemple de rapport :
Name Stmts Miss Cover
-------------------------------------
moyenne.py 3 0 100%
calcul.py 25 5 80%
utils.py 12 3 75%
Ne faites pas de la couverture un but en soi — un test qui exécute du code sans vraiment le vérifier ne sert à rien.
doctestUne autre forme de tests : les exemples dans les docstrings sont automatiquement exécutables.
def carre(x):
"""Renvoie le carré d'un nombre.
>>> carre(2)
4
>>> carre(0)
0
>>> carre(-3)
9
"""
return x ** 2
# Lancer les doctests
import doctest
resultats = doctest.testmod(verbose=False)
print(f"{resultats.attempted} tests, {resultats.failed} échoués")3 tests, 0 échoués
doctest lit les >>> dans les docstrings, exécute les expressions, et vérifie que la sortie correspond. Documentation testable — élégant.
Limitations : pas adapté aux cas complexes. Pour de vrais tests unitaires, pytest.
Le TDD (développement dirigé par les tests) est une pratique où on écrit les tests AVANT le code. Cycle en 3 étapes :
Avantages :
Difficultés :
C’est une pratique à connaître, à essayer, mais pas un dogme.
# ❌ Test fourre-tout
def test_compte():
compte = CompteBancaire(100)
compte.deposer(50)
assert compte.solde == 150
compte.retirer(20)
assert compte.solde == 130
with pytest.raises(ValueError):
compte.retirer(1000)
# ✅ Trois tests distincts
def test_depot():
compte = CompteBancaire(100)
compte.deposer(50)
assert compte.solde == 150
def test_retrait():
compte = CompteBancaire(100)
compte.retirer(20)
assert compte.solde == 80
def test_retrait_excessif():
compte = CompteBancaire(100)
with pytest.raises(ValueError):
compte.retirer(1000)Si plusieurs tests échouent, on voit lesquels → debug plus facile.
Le nom du test doit décrire le cas.
# ❌ Peu clair
def test_1(): ...
def test_panier(): ...
# ✅ Clair
def test_retrait_refuse_si_fonds_insuffisants(): ...
def test_panier_vide_renvoie_total_zero(): ...def test_depot_augmente_solde():
# Arrange : préparer
compte = CompteBancaire(100)
# Act : agir
compte.deposer(50)
# Assert : vérifier
assert compte.solde == 150Un test ne doit jamais dépendre du résultat d’un autre. Chaque test crée son propre contexte (via setUp ou une fixture).
Une suite qui prend 5 minutes, personne ne la lance. Visez < 10 secondes pour les tests unitaires. Les gros tests (intégration, E2E) sont séparés.
Dans unittest, une méthode de test doit :
test__testtest()a) — le préfixe test_ est détecté automatiquement par unittest et pytest. Les autres méthodes dans la classe ne sont pas lancées comme tests.
Quel est l’avantage principal de pytest sur unittest ?
assert, rapports détaillésb) — pytest n’exige pas de classes, utilise le assert natif, et donne des messages d’erreur beaucoup plus informatifs. C’est aussi plus léger en boilerplate.
À quoi sert la méthode setUp dans unittest.TestCase ?
b) — setUp est appelée avant chaque méthode test_*, ce qui garantit un état frais. tearDown est le symétrique après chaque test.
Comment tester qu’une fonction lève une exception en pytest ?
try: fonction(); except: passassert fonction().raises(Exception)with pytest.raises(ValueError): fonction()@pytest.exception(ValueError)c) — pattern standard. Le test échoue si l’exception n’est pas levée.
import pytest
with pytest.raises(ValueError):
int("abc") # lève bien ValueError → test passeQu’est-ce qu’une fixture en pytest ?
b) — une fixture est une fonction décorée par @pytest.fixture qui fournit une valeur. Les tests qui l’acceptent en paramètre reçoivent automatiquement cette valeur.
Que fait @pytest.mark.parametrize("entree, attendu", [(2, 4), (3, 9)]) ?
b) — pytest multiplie le test pour chaque jeu de paramètres. Rapport détaillé cas par cas.
Quelle couverture de tests viser en général ?
c) — 80-90 % est un objectif réaliste. Tendre vers 100 % mène souvent à tester du code défensif inutile. La qualité des tests compte plus que le pourcentage.
Dans le pattern AAA, que signifient les trois lettres ?
b) Arrange, Act, Assert — préparer le contexte, exécuter l’action, vérifier le résultat. Structure claire pour tout test.
Écrivez des tests unittest pour la fonction suivante, couvrant les cas normaux et limites.
def est_premier(n):
if n < 2:
return False
for i in range(2, int(n ** 0.5) + 1):
if n % i == 0:
return False
return TrueÉcrire au moins 6 tests.
import unittest
def est_premier(n):
if n < 2:
return False
for i in range(2, int(n ** 0.5) + 1):
if n % i == 0:
return False
return True
class TestEstPremier(unittest.TestCase):
def test_2_est_premier(self):
self.assertTrue(est_premier(2))
def test_3_est_premier(self):
self.assertTrue(est_premier(3))
def test_17_est_premier(self):
self.assertTrue(est_premier(17))
def test_4_non_premier(self):
self.assertFalse(est_premier(4))
def test_1_non_premier(self):
self.assertFalse(est_premier(1))
def test_0_non_premier(self):
self.assertFalse(est_premier(0))
def test_negatif_non_premier(self):
self.assertFalse(est_premier(-7))
def test_grand_premier(self):
self.assertTrue(est_premier(997))
suite = unittest.TestLoader().loadTestsFromTestCase(TestEstPremier)
unittest.TextTestRunner(verbosity=2).run(suite)<unittest.runner.TextTestResult run=8 errors=0 failures=0>
Écrivez des tests pour cette classe Pile (stack LIFO), en utilisant setUp pour créer une pile fraîche pour chaque test.
class Pile:
def __init__(self):
self._items = []
def empiler(self, x):
self._items.append(x)
def depiler(self):
if not self._items:
raise IndexError("pile vide")
return self._items.pop()
def sommet(self):
if not self._items:
raise IndexError("pile vide")
return self._items[-1]
def __len__(self):
return len(self._items)Écrire les tests avec setUp.
import unittest
class Pile:
def __init__(self):
self._items = []
def empiler(self, x):
self._items.append(x)
def depiler(self):
if not self._items:
raise IndexError("pile vide")
return self._items.pop()
def sommet(self):
if not self._items:
raise IndexError("pile vide")
return self._items[-1]
def __len__(self):
return len(self._items)
class TestPile(unittest.TestCase):
def setUp(self):
self.pile = Pile()
def test_pile_vide(self):
self.assertEqual(len(self.pile), 0)
def test_empiler_augmente_longueur(self):
self.pile.empiler(1)
self.assertEqual(len(self.pile), 1)
def test_depiler_lifo(self):
self.pile.empiler(1)
self.pile.empiler(2)
self.pile.empiler(3)
self.assertEqual(self.pile.depiler(), 3)
self.assertEqual(self.pile.depiler(), 2)
self.assertEqual(self.pile.depiler(), 1)
def test_sommet_ne_retire_pas(self):
self.pile.empiler(42)
self.assertEqual(self.pile.sommet(), 42)
self.assertEqual(len(self.pile), 1)
def test_depiler_pile_vide_leve(self):
with self.assertRaises(IndexError):
self.pile.depiler()
def test_sommet_pile_vide_leve(self):
with self.assertRaises(IndexError):
self.pile.sommet()
suite = unittest.TestLoader().loadTestsFromTestCase(TestPile)
unittest.TextTestRunner(verbosity=2).run(suite)<unittest.runner.TextTestResult run=6 errors=0 failures=0>
setUp crée une Pile() fraîche pour chaque test — aucun test ne pollue un autre.sommet(), et les exceptions.Écrivez des tests pytest paramétrés pour la fonction mention(note) suivante.
def mention(note):
if note >= 16: return "Très bien"
if note >= 14: return "Bien"
if note >= 12: return "Assez bien"
if note >= 10: return "Passable"
return "Insuffisant"Créez des tests paramétrés pour tous les cas et les frontières (10, 12, 14, 16 exactement).
# fichier : test_mention.py
import pytest
def mention(note):
if note >= 16: return "Très bien"
if note >= 14: return "Bien"
if note >= 12: return "Assez bien"
if note >= 10: return "Passable"
return "Insuffisant"
@pytest.mark.parametrize("note, attendu", [
# Cas clairement dans chaque zone
(18, "Très bien"),
(15, "Bien"),
(13, "Assez bien"),
(11, "Passable"),
(5, "Insuffisant"),
# Frontières exactes
(16, "Très bien"),
(14, "Bien"),
(12, "Assez bien"),
(10, "Passable"),
# Juste en dessous des frontières
(15.99, "Bien"),
(13.99, "Assez bien"),
(11.99, "Passable"),
(9.99, "Insuffisant"),
# Cas limites
(0, "Insuffisant"),
(20, "Très bien"),
])
def test_mention(note, attendu):
assert mention(note) == attendudef mention(note):
if note >= 16: return "Très bien"
if note >= 14: return "Bien"
if note >= 12: return "Assez bien"
if note >= 10: return "Passable"
return "Insuffisant"
cas = [
(18, "Très bien"), (16, "Très bien"), (15.99, "Bien"), (15, "Bien"),
(14, "Bien"), (13.99, "Assez bien"), (12, "Assez bien"),
(11, "Passable"), (10, "Passable"), (9.99, "Insuffisant"),
(0, "Insuffisant"), (20, "Très bien"),
]
ok = 0
for note, attendu in cas:
obtenu = mention(note)
if obtenu == attendu:
ok += 1
else:
print(f" ❌ mention({note}) = {obtenu!r}, attendu {attendu!r}")
print(f"{ok}/{len(cas)} tests réussis.")12/12 tests réussis.
def test_... pour chaque cas.Écrivez une classe PanierAchats avec ses tests en TDD (tests d’abord).
Spécifications du PanierAchats :
ajouter(article, prix) — ajoute un article.supprimer(article) — enlève un article (lève KeyError si absent).total() — renvoie le total.__len__() — nombre d’articles.Commencez par écrire les tests, puis la classe.
Les tests d’abord :
import unittest
# On suppose que PanierAchats existe (à écrire ensuite)
class PanierAchats:
def __init__(self):
self._articles = {}
def ajouter(self, article, prix):
self._articles[article] = prix
def supprimer(self, article):
if article not in self._articles:
raise KeyError(f"{article} n'est pas dans le panier")
del self._articles[article]
def total(self):
return sum(self._articles.values())
def __len__(self):
return len(self._articles)
class TestPanierAchats(unittest.TestCase):
def setUp(self):
self.panier = PanierAchats()
# Vide
def test_panier_initial_vide(self):
self.assertEqual(len(self.panier), 0)
def test_total_initial_zero(self):
self.assertEqual(self.panier.total(), 0)
# Ajout
def test_ajouter_augmente_taille(self):
self.panier.ajouter("pain", 1.20)
self.assertEqual(len(self.panier), 1)
def test_ajouter_augmente_total(self):
self.panier.ajouter("pain", 1.20)
self.panier.ajouter("lait", 0.95)
self.assertAlmostEqual(self.panier.total(), 2.15)
# Suppression
def test_supprimer_diminue_taille(self):
self.panier.ajouter("pain", 1.20)
self.panier.supprimer("pain")
self.assertEqual(len(self.panier), 0)
def test_supprimer_absent_leve(self):
with self.assertRaises(KeyError):
self.panier.supprimer("fantome")
suite = unittest.TestLoader().loadTestsFromTestCase(TestPanierAchats)
unittest.TextTestRunner(verbosity=2).run(suite)<unittest.runner.TextTestResult run=6 errors=0 failures=0>
test_panier_initial_vide — il échoue (pas de classe).class PanierAchats: pass et __len__.test_ajouter_augmente_taille → échoue.ajouter.À chaque étape, on n’écrit que le nécessaire pour passer le test courant, sans anticiper.
unittest : bibliothèque standard, style classe + self.assert*.pytest : standard de facto moderne. Syntaxe def test_...: assert .... Plus concis, messages d’erreur excellents.setUp (unittest) / fixtures (pytest) : préparer un contexte frais pour chaque test.assertRaises / pytest.raises pour tester les exceptions.@pytest.mark.parametrize pour tester plusieurs cas sans dupliquer le code.← Chapitre précédent : Qualité du code • Chapitre suivant : Type hints →