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.
Pourquoi tester ?
Sans tests :
- Chaque modification peut casser du code existant sans qu'on le sache.
- Les bugs découverts en production coûtent 100 à 1000× plus cher qu'en développement.
- On devient prudent avec son propre code, on évite de le modifier — il « rouille ».
Avec des tests :
- Une modification qui casse quelque chose est détectée immédiatement.
- On refactorise sans peur.
- La suite de tests devient une documentation vivante du comportement attendu.
- Les bugs trouvés deviennent des tests : on ne les reproduit plus.
Terminologie
- Test unitaire : teste une unité (une fonction, une méthode) de façon isolée.
- Test d'intégration : teste plusieurs composants ensemble.
- Test de bout en bout (E2E) : teste l'application complète.
Ce chapitre couvre les tests unitaires.
Tester à la main vs tester avec un framework
À la main (avec 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 !")C'est un début, mais limité :
- Dès qu'un
assertéchoue, tout s'arrête. - Pas de rapport détaillé.
- Difficile de tester les exceptions.
- Pas de découverte automatique des tests.
Les frameworks résolvent tout ça.
unittest — le framework standard
unittest est dans la bibliothèque standard. Inspiré de JUnit (Java), il utilise une approche orientée objet.
Structure de base
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 cette page, on lance la suite à la main :
suite = unittest.TestLoader().loadTestsFromTestCase(TestAdditionner)
runner = unittest.TextTestRunner(verbosity=2)
runner.run(suite)Anatomie
- Une classe qui hérite de
unittest.TestCase. - Chaque méthode commence par
test_— c'est le framework qui les détecte. - On utilise des méthodes
self.assert*pour vérifier.
Les principales assertions
| 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) |
Tester une exception
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)setUp et tearDown
Mé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)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 moderne
pytest 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.
Syntaxe minimaliste
# 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écisAvantages de pytest
- Juste des fonctions avec
assert— pas de classe à écrire. - Messages d'erreur détaillés qui montrent les valeurs.
- Pas d'
assertEqualetc. — le bon vieuxassertsuffit. - Fixtures (voir plus bas) plus puissantes que
setUp. - Plugins nombreux (coverage, parallelisme, etc.).
Tester les exceptions
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).
Fixtures
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.
Paramétriser les tests
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.